From 46c0125011d39f6e1ae0b86f19b53bd05559a2b0 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 12:42:24 -0700 Subject: [PATCH 01/69] Capability composition architecture (#931) Co-authored-by: Claude Opus 4.6 --- .github/workflows/test.yml | 2 +- creating-capabilities.md | 233 ++++ .../_getting-started/installation.md | 10 +- pylabrobot/agilent/__init__.py | 10 + pylabrobot/agilent/biotek/__init__.py | 9 + .../biotek/biotek.py} | 178 ++- .../biotek}/biotek_tests.py | 55 +- .../biotek/cytation.py} | 307 +++-- pylabrobot/agilent/biotek/synergy_h1.py | 150 +++ pylabrobot/azenta/__init__.py | 2 + .../{sealing/a4s_backend.py => azenta/a4s.py} | 212 ++-- .../xpeel_backend.py => azenta/xpeel.py} | 191 +--- pylabrobot/barcode_scanners/__init__.py | 3 - pylabrobot/bmg_labtech/__init__.py | 1 + .../clariostar.py} | 347 +++--- pylabrobot/byonoy/__init__.py | 21 + pylabrobot/byonoy/absorbance_96.py | 304 +++++ pylabrobot/byonoy/backend.py | 95 ++ pylabrobot/byonoy/luminescence_96.py | 307 +++++ pylabrobot/capabilities/__init__.py | 1 + .../automated_retrieval/__init__.py | 2 + .../automated_retrieval.py | 23 + .../automated_retrieval/backend.py | 16 + .../capabilities/barcode_scanning/__init__.py | 2 + .../capabilities/barcode_scanning/backend.py | 16 + .../barcode_scanning/barcode_scanning.py} | 12 +- pylabrobot/capabilities/capability.py | 62 + .../capabilities/fan_control/__init__.py | 2 + .../capabilities/fan_control/backend.py | 15 + .../fan_control/fan_control.py} | 28 +- .../humidity_controlling/__init__.py | 2 + .../humidity_controlling/backend.py | 20 + .../humidity_controller.py | 28 + .../capabilities/microscopy/__init__.py | 18 + pylabrobot/capabilities/microscopy/backend.py | 44 + .../capabilities/microscopy/chatterbox.py | 50 + .../capabilities/microscopy/microscopy.py | 325 ++++++ .../microscopy/microscopy_tests.py | 193 ++++ .../capabilities/microscopy/standard.py | 130 +++ pylabrobot/capabilities/peeling/__init__.py | 2 + pylabrobot/capabilities/peeling/backend.py | 15 + .../peeling/peeling.py} | 11 +- .../capabilities/plate_reading/__init__.py | 1 + .../plate_reading/absorbance/__init__.py | 3 + .../plate_reading/absorbance/absorbance.py | 46 + .../absorbance/absorbance_tests.py | 130 +++ .../plate_reading/absorbance/backend.py | 28 + .../plate_reading/absorbance/chatterbox.py | 34 + .../plate_reading/absorbance/standard.py | 21 + .../plate_reading/fluorescence/__init__.py | 3 + .../plate_reading/fluorescence/backend.py | 35 + .../plate_reading/fluorescence/chatterbox.py | 40 + .../fluorescence/fluorescence.py | 55 + .../fluorescence/fluorescence_tests.py | 164 +++ .../plate_reading/fluorescence/standard.py | 23 + .../plate_reading/luminescence/__init__.py | 3 + .../plate_reading/luminescence/backend.py | 28 + .../plate_reading/luminescence/chatterbox.py | 33 + .../luminescence/luminescence.py | 46 + .../luminescence/luminescence_tests.py | 126 +++ .../plate_reading/luminescence/standard.py | 19 + .../capabilities/plate_reading/utils.py | 18 + pylabrobot/capabilities/sealing/__init__.py | 2 + pylabrobot/capabilities/sealing/backend.py | 19 + pylabrobot/capabilities/sealing/sealing.py | 23 + pylabrobot/capabilities/shaking/__init__.py | 2 + pylabrobot/capabilities/shaking/backend.py | 28 + pylabrobot/capabilities/shaking/shaking.py | 47 + .../temperature_controlling/__init__.py | 2 + .../temperature_controlling/backend.py | 24 + .../temperature_controller.py | 48 +- pylabrobot/capabilities/tilting/__init__.py | 2 + pylabrobot/capabilities/tilting/backend.py | 19 + pylabrobot/capabilities/tilting/tilting.py | 36 + pylabrobot/capabilities/weighing/__init__.py | 2 + pylabrobot/capabilities/weighing/backend.py | 19 + pylabrobot/capabilities/weighing/weighing.py | 23 + pylabrobot/device.py | 122 ++ .../liquid_classes => hamilton}/__init__.py | 0 pylabrobot/hamilton/heater_shaker/__init__.py | 3 + .../heater_shaker/backend.py} | 128 +-- pylabrobot/hamilton/heater_shaker/box.py | 52 + .../hamilton/heater_shaker/heater_shaker.py | 49 + pylabrobot/hamilton/only_fans/__init__.py | 2 + pylabrobot/hamilton/only_fans/backend.py | 169 +++ pylabrobot/hamilton/only_fans/hepa_fan.py | 17 + pylabrobot/hamilton/tilt_module/__init__.py | 2 + .../tilt_module/backend.py} | 79 +- .../hamilton/tilt_module/tilt_module.py | 152 +++ pylabrobot/heating_shaking/__init__.py | 16 - .../heating_shaking/bioshake_backend.py | 268 ----- pylabrobot/heating_shaking/chatterbox.py | 9 - .../inheco/thermoshake_backend.py | 89 -- pylabrobot/inheco/__init__.py | 9 + .../inheco/control_box.py | 5 +- pylabrobot/inheco/cpac.py | 144 +++ pylabrobot/inheco/scila/__init__.py | 3 + .../inheco/scila/inheco_sila_interface.py | 86 +- pylabrobot/inheco/scila/scila.py | 18 + .../inheco/scila/scila_backend.py | 74 +- .../inheco/scila/scila_backend_tests.py | 237 ++++ pylabrobot/{storage => }/inheco/scila/soap.py | 0 pylabrobot/inheco/thermoshake.py | 174 +++ pylabrobot/io/validation.py | 4 +- .../keyence/__init__.py | 0 .../keyence/keyence_backend.py | 4 +- .../tecan/spark20m => legacy}/__init__.py | 0 pylabrobot/{ => legacy}/arms/__init__.py | 0 pylabrobot/{ => legacy}/arms/backend.py | 4 +- .../arms/precise_flex/__init__.py | 0 .../{ => legacy}/arms/precise_flex/coords.py | 2 +- .../arms/precise_flex/error_codes.py | 0 .../{ => legacy}/arms/precise_flex/joints.py | 0 .../{ => legacy}/arms/precise_flex/pf_3400.py | 2 +- .../{ => legacy}/arms/precise_flex/pf_400.py | 2 +- .../arms/precise_flex/precise_flex_backend.py | 10 +- .../precise_flex_backend_tests.py | 13 +- pylabrobot/{ => legacy}/arms/scara.py | 6 +- pylabrobot/{ => legacy}/arms/scara_tests.py | 6 +- pylabrobot/{ => legacy}/arms/standard.py | 0 .../legacy/barcode_scanners/__init__.py | 6 + .../{ => legacy}/barcode_scanners/backend.py | 4 +- .../barcode_scanners/barcode_scanner.py | 20 + .../barcode_scanners/keyence/__init__.py | 3 + .../keyence/keyence_backend.py | 3 + .../{ => legacy}/centrifuge/__init__.py | 0 pylabrobot/{ => legacy}/centrifuge/access2.py | 4 +- pylabrobot/{ => legacy}/centrifuge/backend.py | 2 +- .../{ => legacy}/centrifuge/centrifuge.py | 6 +- .../centrifuge/centrifuge_tests.py | 13 +- .../{ => legacy}/centrifuge/chatterbox.py | 2 +- .../{ => legacy}/centrifuge/standard.py | 0 .../{ => legacy}/centrifuge/vspin_backend.py | 0 pylabrobot/legacy/heating_shaking/__init__.py | 16 + .../{ => legacy}/heating_shaking/backend.py | 4 +- .../heating_shaking/bioshake_backend.py | 58 + .../legacy/heating_shaking/chatterbox.py | 9 + .../heating_shaking/hamilton_backend.py | 89 ++ .../heating_shaking/heater_shaker.py | 6 +- .../heating_shaking/heater_shaker_tests.py | 2 +- .../heating_shaking/inheco/__init__.py | 0 .../heating_shaking/inheco/thermoshake.py | 6 +- .../inheco/thermoshake_backend.py | 78 ++ .../{ => legacy}/liquid_handling/__init__.py | 0 .../liquid_handling/backends/__init__.py | 0 .../liquid_handling/backends/backend.py | 4 +- .../liquid_handling/backends/chatterbox.py | 4 +- .../backends/chatterbox_backend.py | 0 .../backends/chatterbox_tests.py | 4 +- .../backends/hamilton/STAR_backend.py | 16 +- .../backends/hamilton/STAR_chatterbox.py | 4 +- .../backends/hamilton/STAR_tests.py | 8 +- .../backends/hamilton/__init__.py | 0 .../liquid_handling/backends/hamilton/base.py | 4 +- .../backends/hamilton/common.py | 0 .../backends/hamilton/nimbus_backend.py | 16 +- .../backends/hamilton/nimbus_backend_tests.py | 13 +- .../backends/hamilton/planning.py | 2 +- .../backends/hamilton/planning_tests.py | 0 .../liquid_handling/backends/hamilton/pump.py | 2 +- .../backends/hamilton/tcp/__init__.py | 0 .../backends/hamilton/tcp/commands.py | 6 +- .../backends/hamilton/tcp/introspection.py | 11 +- .../backends/hamilton/tcp/messages.py | 4 +- .../backends/hamilton/tcp/packets.py | 0 .../backends/hamilton/tcp/protocol.py | 0 .../backends/hamilton/tcp/tcp_tests.py | 8 +- .../backends/hamilton/tcp_backend.py | 10 +- .../backends/hamilton/vantage_backend.py | 6 +- .../backends/hamilton/vantage_tests.py | 4 +- .../backends/opentrons_backend.py | 6 +- .../backends/opentrons_backend_tests.py | 8 +- .../backends/opentrons_simulator.py | 6 +- .../backends/serializing_backend.py | 4 +- .../backends/serializing_backend_tests.py | 4 +- .../backends/tecan/EVO_backend.py | 8 +- .../backends/tecan/EVO_tests.py | 4 +- .../backends/tecan/__init__.py | 0 .../liquid_handling/backends/tecan/errors.py | 0 .../{ => legacy}/liquid_handling/errors.py | 0 .../liquid_classes/__init__.py | 0 .../liquid_classes/hamilton/__init__.py | 0 .../liquid_classes/hamilton/base.py | 0 .../liquid_classes/hamilton/star.py | 2 +- .../liquid_classes/hamilton/vantage.py | 2 +- .../liquid_handling/liquid_classes/tecan.py | 0 .../liquid_handling/liquid_handler.py | 12 +- .../liquid_handling/liquid_handler_tests.py | 10 +- .../{ => legacy}/liquid_handling/standard.py | 0 .../liquid_handling/strictness.py | 0 .../{ => legacy}/liquid_handling/utils.py | 0 pylabrobot/{ => legacy}/machines/__init__.py | 1 + pylabrobot/{ => legacy}/machines/backend.py | 0 pylabrobot/{ => legacy}/machines/machine.py | 17 +- .../{ => legacy}/machines/machine_tests.py | 3 +- .../molecular_devices/pico/backend.py | 125 ++ .../molecular_devices/pico/backend_tests.py | 16 +- pylabrobot/{ => legacy}/only_fans/__init__.py | 0 pylabrobot/{ => legacy}/only_fans/backend.py | 12 +- .../{ => legacy}/only_fans/chatterbox.py | 6 +- pylabrobot/legacy/only_fans/fan.py | 47 + .../only_fans/hamilton_hepa_fan_backend.py | 32 + pylabrobot/{ => legacy}/peeling/__init__.py | 0 pylabrobot/{ => legacy}/peeling/backend.py | 4 +- pylabrobot/legacy/peeling/peeler.py | 19 + pylabrobot/legacy/peeling/xpeel.py | 11 + pylabrobot/legacy/peeling/xpeel_backend.py | 68 ++ .../{ => legacy}/plate_reading/__init__.py | 2 - .../legacy/plate_reading/agilent/__init__.py | 3 + .../plate_reading/agilent/biotek_backend.py | 205 ++++ .../agilent/biotek_cytation_backend.py | 140 +++ .../agilent/biotek_synergyh1_backend.py | 23 + .../{ => legacy}/plate_reading/backend.py | 6 +- .../plate_reading/biotek_backend.py | 0 .../plate_reading/bmg_labtech/__init__.py | 0 .../bmg_labtech/clario_star_backend.py | 95 ++ .../plate_reading/byonoy/__init__.py | 15 +- .../plate_reading/byonoy/byonoy_a96a.py | 40 + .../plate_reading/byonoy/byonoy_backend.py | 117 ++ .../legacy/plate_reading/byonoy/byonoy_l96.py | 66 ++ .../plate_reading/byonoy/byonoy_l96a.py | 40 +- .../plate_reading/byonoy/byonoy_tests.py | 4 +- pylabrobot/legacy/plate_reading/chatterbox.py | 89 ++ .../plate_reading/clario_star_backend.py | 0 .../plate_reading/image_reader.py | 8 +- pylabrobot/legacy/plate_reading/imager.py | 160 +++ .../molecular_devices/__init__.py | 5 +- .../molecular_devices/backend.py | 319 ++++++ .../molecular_devices/backend_tests.py | 1001 +++++++++++++++++ .../spectramax_384_plus_backend.py | 31 +- .../spectramax_m5_backend.py | 4 +- .../plate_reading/plate_reader.py | 6 +- .../plate_reading/plate_reader_tests.py | 4 +- .../{ => legacy}/plate_reading/standard.py | 0 .../plate_reading/tecan/__init__.py | 0 .../plate_reading/tecan/infinite_backend.py | 2 +- .../tecan/infinite_backend_tests.py | 4 +- .../plate_reading/tecan/spark20m/__init__.py | 0 .../tecan/spark20m/controls/__init__.py | 0 .../tecan/spark20m/controls/base_control.py | 0 .../tecan/spark20m/controls/camera_control.py | 0 .../tecan/spark20m/controls/config_control.py | 0 .../tecan/spark20m/controls/data_control.py | 0 .../tecan/spark20m/controls/gas_control.py | 0 .../spark20m/controls/injector_control.py | 0 .../spark20m/controls/measurement_control.py | 0 .../spark20m/controls/movement_control.py | 0 .../tecan/spark20m/controls/optics_control.py | 0 .../controls/plate_transport_control.py | 0 .../tecan/spark20m/controls/sensor_control.py | 0 .../tecan/spark20m/controls/spark_enums.py | 0 .../tecan/spark20m/controls/system_control.py | 0 .../plate_reading/tecan/spark20m/enums.py | 0 .../tecan/spark20m/spark_backend.py | 4 +- .../tecan/spark20m/spark_backend_tests.py | 10 +- .../tecan/spark20m/spark_packet_parser.py | 0 .../tecan/spark20m/spark_processor.py | 0 .../tecan/spark20m/spark_processor_tests.py | 17 +- .../tecan/spark20m/spark_reader_async.py | 0 .../spark20m/spark_reader_async_tests.py | 19 +- .../{ => legacy}/plate_reading/utils.py | 0 .../{ => legacy}/plate_washing/__init__.py | 0 .../plate_washing/biotek/__init__.py | 2 +- .../plate_washing/biotek/el406/__init__.py | 0 .../plate_washing/biotek/el406/actions.py | 0 .../biotek/el406/actions_tests.py | 4 +- .../plate_washing/biotek/el406/backend.py | 2 +- .../plate_washing/biotek/el406/batch_tests.py | 2 +- .../biotek/el406/communication.py | 0 .../biotek/el406/communication_tests.py | 2 +- .../plate_washing/biotek/el406/enums.py | 0 .../plate_washing/biotek/el406/error_codes.py | 0 .../plate_washing/biotek/el406/errors.py | 0 .../plate_washing/biotek/el406/helpers.py | 0 .../biotek/el406/helpers_tests.py | 8 +- .../plate_washing/biotek/el406/mock_tests.py | 2 +- .../plate_washing/biotek/el406/protocol.py | 0 .../plate_washing/biotek/el406/queries.py | 0 .../biotek/el406/queries_tests.py | 4 +- .../plate_washing/biotek/el406/setup_tests.py | 4 +- .../biotek/el406/steps/__init__.py | 0 .../plate_washing/biotek/el406/steps/_base.py | 0 .../biotek/el406/steps/_manifold.py | 0 .../biotek/el406/steps/_peristaltic.py | 0 .../biotek/el406/steps/_shake.py | 0 .../biotek/el406/steps/_syringe.py | 0 .../biotek/el406/steps_aspirate_tests.py | 4 +- .../biotek/el406/steps_dispense_tests.py | 4 +- .../biotek/el406/steps_peristaltic_tests.py | 4 +- .../biotek/el406/steps_prime_tests.py | 4 +- .../biotek/el406/steps_shake_tests.py | 4 +- .../biotek/el406/steps_wash_tests.py | 4 +- .../powder_dispensing/__init__.py | 0 .../{ => legacy}/powder_dispensing/backend.py | 2 +- .../powder_dispensing/chatterbox.py | 2 +- .../chemspeed/crystal_powderdose.py | 2 +- .../powder_dispensing/powder_dispenser.py | 2 +- .../powder_dispenser_tests.py | 4 +- pylabrobot/{ => legacy}/pumps/__init__.py | 0 .../{ => legacy}/pumps/agrowpumps/__init__.py | 0 .../pumps/agrowpumps/agrowdosepump_backend.py | 2 +- .../pumps/agrowpumps/agrowdosepump_tests.py | 4 +- pylabrobot/{ => legacy}/pumps/backend.py | 2 +- pylabrobot/{ => legacy}/pumps/calibration.py | 0 .../{ => legacy}/pumps/calibration_tests.py | 2 +- pylabrobot/{ => legacy}/pumps/chatterbox.py | 2 +- .../pumps/cole_parmer/__init__.py | 0 .../pumps/cole_parmer/masterflex_backend.py | 2 +- pylabrobot/{ => legacy}/pumps/errors.py | 0 pylabrobot/{ => legacy}/pumps/pump.py | 2 +- pylabrobot/{ => legacy}/pumps/pump_tests.py | 10 +- pylabrobot/{ => legacy}/pumps/pumparray.py | 8 +- pylabrobot/legacy/scales/__init__.py | 2 + pylabrobot/{ => legacy}/scales/chatterbox.py | 2 +- .../legacy/scales/mettler_toledo_backend.py | 116 ++ pylabrobot/{ => legacy}/scales/scale.py | 4 +- .../{ => legacy}/scales/scale_backend.py | 11 +- pylabrobot/{ => legacy}/sealing/__init__.py | 0 pylabrobot/{ => legacy}/sealing/a4s.py | 4 +- pylabrobot/legacy/sealing/a4s_backend.py | 50 + pylabrobot/{ => legacy}/sealing/backend.py | 2 +- pylabrobot/legacy/sealing/sealer.py | 28 + pylabrobot/{ => legacy}/shaking/__init__.py | 0 pylabrobot/{ => legacy}/shaking/backend.py | 4 +- pylabrobot/{ => legacy}/shaking/chatterbox.py | 2 +- pylabrobot/legacy/shaking/shaker.py | 90 ++ .../{ => legacy}/shaking/shaker_tests.py | 2 +- pylabrobot/{ => legacy}/storage/__init__.py | 0 pylabrobot/{ => legacy}/storage/backend.py | 2 +- pylabrobot/{ => legacy}/storage/chatterbox.py | 2 +- pylabrobot/legacy/storage/cytomat/__init__.py | 2 + .../legacy/storage/cytomat/constants.py | 3 + pylabrobot/legacy/storage/cytomat/cytomat.py | 175 +++ pylabrobot/legacy/storage/cytomat/errors.py | 3 + .../cytomat/heraeus_cytomat_backend.py | 67 ++ pylabrobot/legacy/storage/cytomat/racks.py | 3 + pylabrobot/legacy/storage/cytomat/schemas.py | 3 + pylabrobot/legacy/storage/cytomat/utils.py | 3 + pylabrobot/{ => legacy}/storage/incubator.py | 2 +- .../{ => legacy}/storage/incubator_tests.py | 2 +- .../{ => legacy}/storage/inheco/__init__.py | 0 .../storage/inheco/incubator_shaker.py | 2 +- .../inheco/incubator_shaker_backend.py | 2 +- .../storage/inheco/scila/__init__.py | 0 .../inheco/scila/inheco_sila_interface.py | 6 + .../storage/inheco/scila/scila_backend.py | 74 ++ .../inheco/scila/scila_backend_tests.py | 6 +- .../legacy/storage/inheco/scila/soap.py | 4 + .../{ => legacy}/storage/liconic/__init__.py | 0 .../legacy/storage/liconic/constants.py | 3 + pylabrobot/legacy/storage/liconic/errors.py | 3 + .../legacy/storage/liconic/liconic_backend.py | 268 +++++ .../storage/liconic/liconic_backend_tests.py | 12 +- pylabrobot/legacy/storage/liconic/racks.py | 3 + .../temperature_controlling/__init__.py | 0 .../temperature_controlling/backend.py | 4 +- .../temperature_controlling/chatterbox.py | 2 +- .../inheco/__init__.py | 0 .../inheco/control_box.py | 5 + .../temperature_controlling/inheco/cpac.py | 6 +- .../inheco/cpac_backend.py | 11 + .../inheco/temperature_controller.py | 51 + .../temperature_controlling/opentrons.py | 12 +- .../opentrons_backend.py | 2 +- .../opentrons_backend_usb.py | 2 +- .../temperature_controller.py | 97 ++ .../temperature_controller_tests.py | 6 +- .../{ => legacy}/thermocycling/__init__.py | 0 .../{ => legacy}/thermocycling/backend.py | 4 +- .../{ => legacy}/thermocycling/chatterbox.py | 4 +- .../thermocycling/chatterbox_tests.py | 4 +- .../thermocycling/inheco/__init__.py | 0 .../thermocycling/inheco/odtc_backend.py | 9 +- .../{ => legacy}/thermocycling/opentrons.py | 4 +- .../thermocycling/opentrons_backend.py | 4 +- .../thermocycling/opentrons_backend_tests.py | 24 +- .../thermocycling/opentrons_backend_usb.py | 4 +- .../{ => legacy}/thermocycling/standard.py | 0 .../thermocycling/thermo_fisher/__init__.py | 0 .../thermocycling/thermo_fisher/atc.py | 2 +- .../thermocycling/thermo_fisher/proflex.py | 2 +- .../thermo_fisher/proflex_tests.py | 4 +- .../thermo_fisher_thermocycler.py | 4 +- .../thermocycling/thermocycler.py | 6 +- .../thermocycling/thermocycler_tests.py | 8 +- pylabrobot/{ => legacy}/tilting/__init__.py | 2 + pylabrobot/legacy/tilting/chatterbox.py | 14 + pylabrobot/legacy/tilting/hamilton.py | 3 + pylabrobot/legacy/tilting/hamilton_backend.py | 7 + pylabrobot/{ => legacy}/tilting/tilter.py | 119 +- .../{ => legacy}/tilting/tilter_backend.py | 7 +- pylabrobot/liconic/__init__.py | 3 + .../liconic_backend.py => liconic/backend.py} | 495 ++++---- pylabrobot/{storage => }/liconic/constants.py | 135 ++- pylabrobot/{storage => }/liconic/errors.py | 2 +- pylabrobot/liconic/liconic.py | 208 ++++ pylabrobot/{storage => }/liconic/racks.py | 0 pylabrobot/mettler_toledo/__init__.py | 1 + .../mettler_toledo.py} | 84 +- pylabrobot/molecular_devices/__init__.py | 9 + .../imageXpress/pico/__init__.py | 2 + .../imageXpress}/pico/backend.py | 120 +- .../imageXpress/pico/pico.py | 61 + .../molecular_devices/spectramax/__init__.py | 23 + .../spectramax}/backend.py | 320 +----- .../spectramax}/backend_tests.py | 127 ++- .../spectramax/spectramax_384_plus.py | 78 ++ .../spectramax/spectramax_m5.py | 385 +++++++ .../only_fans/hamilton_hepa_fan_backend.py | 162 --- pylabrobot/peeling/xpeel.py | 8 - pylabrobot/plate_reading/agilent/__init__.py | 8 - .../agilent/biotek_synergyh1_backend.py | 88 -- .../plate_reading/biotek_cytation_backend.py | 13 - .../plate_reading/biotek_synergyh1_backend.py | 8 - .../plate_reading/byonoy/byonoy_a96a.py | 188 ---- .../plate_reading/byonoy/byonoy_backend.py | 401 ------- pylabrobot/plate_reading/byonoy/byonoy_l96.py | 176 --- pylabrobot/plate_reading/chatterbox.py | 123 -- pylabrobot/plate_reading/imager.py | 367 ------ .../molecular_devices_backend.py | 11 - .../spectramax_384_plus_backend.py | 10 - .../plate_reading/spectramax_m5_backend.py | 10 - pylabrobot/qinstruments/__init__.py | 16 + pylabrobot/qinstruments/bioshake.py | 499 ++++++++ pylabrobot/resources/plate.py | 6 +- pylabrobot/scales/__init__.py | 2 - pylabrobot/sealing/sealer.py | 28 - pylabrobot/shaking/shaker.py | 69 -- pylabrobot/storage/cytomat/__init__.py | 1 - .../inheco/cpac_backend.py | 7 - .../inheco/temperature_controller.py | 83 -- pylabrobot/thermo_fisher/__init__.py | 0 pylabrobot/thermo_fisher/cytomat/__init__.py | 5 + .../cytomat/backend.py} | 185 ++- .../thermo_fisher/cytomat/chatterbox.py | 20 + .../cytomat/constants.py | 0 pylabrobot/thermo_fisher/cytomat/cytomat.py | 193 ++++ .../cytomat/errors.py | 2 +- .../cytomat/heraeus_backend.py} | 135 ++- .../cytomat/racks.py | 0 .../cytomat/schemas.py | 4 +- .../cytomat/utils.py | 0 pylabrobot/tilting/chatterbox.py | 12 - pylabrobot/tilting/hamilton.py | 46 - pyproject.toml | 6 +- 445 files changed, 11626 insertions(+), 4708 deletions(-) create mode 100644 creating-capabilities.md create mode 100644 pylabrobot/agilent/__init__.py create mode 100644 pylabrobot/agilent/biotek/__init__.py rename pylabrobot/{plate_reading/agilent/biotek_backend.py => agilent/biotek/biotek.py} (77%) rename pylabrobot/{plate_reading/agilent => agilent/biotek}/biotek_tests.py (94%) rename pylabrobot/{plate_reading/agilent/biotek_cytation_backend.py => agilent/biotek/cytation.py} (81%) create mode 100644 pylabrobot/agilent/biotek/synergy_h1.py create mode 100644 pylabrobot/azenta/__init__.py rename pylabrobot/{sealing/a4s_backend.py => azenta/a4s.py} (73%) rename pylabrobot/{peeling/xpeel_backend.py => azenta/xpeel.py} (55%) delete mode 100644 pylabrobot/barcode_scanners/__init__.py create mode 100644 pylabrobot/bmg_labtech/__init__.py rename pylabrobot/{plate_reading/bmg_labtech/clario_star_backend.py => bmg_labtech/clariostar.py} (53%) create mode 100644 pylabrobot/byonoy/__init__.py create mode 100644 pylabrobot/byonoy/absorbance_96.py create mode 100644 pylabrobot/byonoy/backend.py create mode 100644 pylabrobot/byonoy/luminescence_96.py create mode 100644 pylabrobot/capabilities/__init__.py create mode 100644 pylabrobot/capabilities/automated_retrieval/__init__.py create mode 100644 pylabrobot/capabilities/automated_retrieval/automated_retrieval.py create mode 100644 pylabrobot/capabilities/automated_retrieval/backend.py create mode 100644 pylabrobot/capabilities/barcode_scanning/__init__.py create mode 100644 pylabrobot/capabilities/barcode_scanning/backend.py rename pylabrobot/{barcode_scanners/barcode_scanner.py => capabilities/barcode_scanning/barcode_scanning.py} (57%) create mode 100644 pylabrobot/capabilities/capability.py create mode 100644 pylabrobot/capabilities/fan_control/__init__.py create mode 100644 pylabrobot/capabilities/fan_control/backend.py rename pylabrobot/{only_fans/fan.py => capabilities/fan_control/fan_control.py} (52%) create mode 100644 pylabrobot/capabilities/humidity_controlling/__init__.py create mode 100644 pylabrobot/capabilities/humidity_controlling/backend.py create mode 100644 pylabrobot/capabilities/humidity_controlling/humidity_controller.py create mode 100644 pylabrobot/capabilities/microscopy/__init__.py create mode 100644 pylabrobot/capabilities/microscopy/backend.py create mode 100644 pylabrobot/capabilities/microscopy/chatterbox.py create mode 100644 pylabrobot/capabilities/microscopy/microscopy.py create mode 100644 pylabrobot/capabilities/microscopy/microscopy_tests.py create mode 100644 pylabrobot/capabilities/microscopy/standard.py create mode 100644 pylabrobot/capabilities/peeling/__init__.py create mode 100644 pylabrobot/capabilities/peeling/backend.py rename pylabrobot/{peeling/peeler.py => capabilities/peeling/peeling.py} (58%) create mode 100644 pylabrobot/capabilities/plate_reading/__init__.py create mode 100644 pylabrobot/capabilities/plate_reading/absorbance/__init__.py create mode 100644 pylabrobot/capabilities/plate_reading/absorbance/absorbance.py create mode 100644 pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py create mode 100644 pylabrobot/capabilities/plate_reading/absorbance/backend.py create mode 100644 pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py create mode 100644 pylabrobot/capabilities/plate_reading/absorbance/standard.py create mode 100644 pylabrobot/capabilities/plate_reading/fluorescence/__init__.py create mode 100644 pylabrobot/capabilities/plate_reading/fluorescence/backend.py create mode 100644 pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py create mode 100644 pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py create mode 100644 pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py create mode 100644 pylabrobot/capabilities/plate_reading/fluorescence/standard.py create mode 100644 pylabrobot/capabilities/plate_reading/luminescence/__init__.py create mode 100644 pylabrobot/capabilities/plate_reading/luminescence/backend.py create mode 100644 pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py create mode 100644 pylabrobot/capabilities/plate_reading/luminescence/luminescence.py create mode 100644 pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py create mode 100644 pylabrobot/capabilities/plate_reading/luminescence/standard.py create mode 100644 pylabrobot/capabilities/plate_reading/utils.py create mode 100644 pylabrobot/capabilities/sealing/__init__.py create mode 100644 pylabrobot/capabilities/sealing/backend.py create mode 100644 pylabrobot/capabilities/sealing/sealing.py create mode 100644 pylabrobot/capabilities/shaking/__init__.py create mode 100644 pylabrobot/capabilities/shaking/backend.py create mode 100644 pylabrobot/capabilities/shaking/shaking.py create mode 100644 pylabrobot/capabilities/temperature_controlling/__init__.py create mode 100644 pylabrobot/capabilities/temperature_controlling/backend.py rename pylabrobot/{ => capabilities}/temperature_controlling/temperature_controller.py (71%) create mode 100644 pylabrobot/capabilities/tilting/__init__.py create mode 100644 pylabrobot/capabilities/tilting/backend.py create mode 100644 pylabrobot/capabilities/tilting/tilting.py create mode 100644 pylabrobot/capabilities/weighing/__init__.py create mode 100644 pylabrobot/capabilities/weighing/backend.py create mode 100644 pylabrobot/capabilities/weighing/weighing.py create mode 100644 pylabrobot/device.py rename pylabrobot/{liquid_handling/liquid_classes => hamilton}/__init__.py (100%) create mode 100644 pylabrobot/hamilton/heater_shaker/__init__.py rename pylabrobot/{heating_shaking/hamilton_backend.py => hamilton/heater_shaker/backend.py} (54%) create mode 100644 pylabrobot/hamilton/heater_shaker/box.py create mode 100644 pylabrobot/hamilton/heater_shaker/heater_shaker.py create mode 100644 pylabrobot/hamilton/only_fans/__init__.py create mode 100644 pylabrobot/hamilton/only_fans/backend.py create mode 100644 pylabrobot/hamilton/only_fans/hepa_fan.py create mode 100644 pylabrobot/hamilton/tilt_module/__init__.py rename pylabrobot/{tilting/hamilton_backend.py => hamilton/tilt_module/backend.py} (81%) create mode 100644 pylabrobot/hamilton/tilt_module/tilt_module.py delete mode 100644 pylabrobot/heating_shaking/__init__.py delete mode 100644 pylabrobot/heating_shaking/bioshake_backend.py delete mode 100644 pylabrobot/heating_shaking/chatterbox.py delete mode 100644 pylabrobot/heating_shaking/inheco/thermoshake_backend.py create mode 100644 pylabrobot/inheco/__init__.py rename pylabrobot/{temperature_controlling => }/inheco/control_box.py (97%) create mode 100644 pylabrobot/inheco/cpac.py create mode 100644 pylabrobot/inheco/scila/__init__.py rename pylabrobot/{storage => }/inheco/scila/inheco_sila_interface.py (76%) create mode 100644 pylabrobot/inheco/scila/scila.py rename pylabrobot/{storage => }/inheco/scila/scila_backend.py (85%) create mode 100644 pylabrobot/inheco/scila/scila_backend_tests.py rename pylabrobot/{storage => }/inheco/scila/soap.py (100%) create mode 100644 pylabrobot/inheco/thermoshake.py rename pylabrobot/{barcode_scanners => }/keyence/__init__.py (100%) rename pylabrobot/{barcode_scanners => }/keyence/keyence_backend.py (95%) rename pylabrobot/{plate_reading/tecan/spark20m => legacy}/__init__.py (100%) rename pylabrobot/{ => legacy}/arms/__init__.py (100%) rename pylabrobot/{ => legacy}/arms/backend.py (96%) rename pylabrobot/{ => legacy}/arms/precise_flex/__init__.py (100%) rename pylabrobot/{ => legacy}/arms/precise_flex/coords.py (81%) rename pylabrobot/{ => legacy}/arms/precise_flex/error_codes.py (100%) rename pylabrobot/{ => legacy}/arms/precise_flex/joints.py (100%) rename pylabrobot/{ => legacy}/arms/precise_flex/pf_3400.py (76%) rename pylabrobot/{ => legacy}/arms/precise_flex/pf_400.py (76%) rename pylabrobot/{ => legacy}/arms/precise_flex/precise_flex_backend.py (99%) rename pylabrobot/{ => legacy}/arms/precise_flex/precise_flex_backend_tests.py (99%) rename pylabrobot/{ => legacy}/arms/scara.py (95%) rename pylabrobot/{ => legacy}/arms/scara_tests.py (94%) rename pylabrobot/{ => legacy}/arms/standard.py (100%) create mode 100644 pylabrobot/legacy/barcode_scanners/__init__.py rename pylabrobot/{ => legacy}/barcode_scanners/backend.py (74%) create mode 100644 pylabrobot/legacy/barcode_scanners/barcode_scanner.py create mode 100644 pylabrobot/legacy/barcode_scanners/keyence/__init__.py create mode 100644 pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py rename pylabrobot/{ => legacy}/centrifuge/__init__.py (100%) rename pylabrobot/{ => legacy}/centrifuge/access2.py (80%) rename pylabrobot/{ => legacy}/centrifuge/backend.py (94%) rename pylabrobot/{ => legacy}/centrifuge/centrifuge.py (97%) rename pylabrobot/{ => legacy}/centrifuge/centrifuge_tests.py (93%) rename pylabrobot/{ => legacy}/centrifuge/chatterbox.py (93%) rename pylabrobot/{ => legacy}/centrifuge/standard.py (100%) rename pylabrobot/{ => legacy}/centrifuge/vspin_backend.py (100%) create mode 100644 pylabrobot/legacy/heating_shaking/__init__.py rename pylabrobot/{ => legacy}/heating_shaking/backend.py (61%) create mode 100644 pylabrobot/legacy/heating_shaking/bioshake_backend.py create mode 100644 pylabrobot/legacy/heating_shaking/chatterbox.py create mode 100644 pylabrobot/legacy/heating_shaking/hamilton_backend.py rename pylabrobot/{ => legacy}/heating_shaking/heater_shaker.py (82%) rename pylabrobot/{ => legacy}/heating_shaking/heater_shaker_tests.py (83%) rename pylabrobot/{ => legacy}/heating_shaking/inheco/__init__.py (100%) rename pylabrobot/{ => legacy}/heating_shaking/inheco/thermoshake.py (86%) create mode 100644 pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py rename pylabrobot/{ => legacy}/liquid_handling/__init__.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/backends/__init__.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/backends/backend.py (97%) rename pylabrobot/{ => legacy}/liquid_handling/backends/chatterbox.py (98%) rename pylabrobot/{ => legacy}/liquid_handling/backends/chatterbox_backend.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/backends/chatterbox_tests.py (94%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/STAR_backend.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/STAR_chatterbox.py (98%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/STAR_tests.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/__init__.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/base.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/common.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/nimbus_backend.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/nimbus_backend_tests.py (98%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/planning.py (97%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/planning_tests.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/pump.py (93%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/tcp/__init__.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/tcp/commands.py (95%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/tcp/introspection.py (98%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/tcp/messages.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/tcp/packets.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/tcp/protocol.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/tcp/tcp_tests.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/tcp_backend.py (97%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/vantage_backend.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/backends/hamilton/vantage_tests.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/backends/opentrons_backend.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/backends/opentrons_backend_tests.py (97%) rename pylabrobot/{ => legacy}/liquid_handling/backends/opentrons_simulator.py (95%) rename pylabrobot/{ => legacy}/liquid_handling/backends/serializing_backend.py (98%) rename pylabrobot/{ => legacy}/liquid_handling/backends/serializing_backend_tests.py (98%) rename pylabrobot/{ => legacy}/liquid_handling/backends/tecan/EVO_backend.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/backends/tecan/EVO_tests.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/backends/tecan/__init__.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/backends/tecan/errors.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/errors.py (100%) create mode 100644 pylabrobot/legacy/liquid_handling/liquid_classes/__init__.py rename pylabrobot/{ => legacy}/liquid_handling/liquid_classes/hamilton/__init__.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/liquid_classes/hamilton/base.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/liquid_classes/hamilton/star.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/liquid_classes/hamilton/vantage.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/liquid_classes/tecan.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/liquid_handler.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/liquid_handler_tests.py (99%) rename pylabrobot/{ => legacy}/liquid_handling/standard.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/strictness.py (100%) rename pylabrobot/{ => legacy}/liquid_handling/utils.py (100%) rename pylabrobot/{ => legacy}/machines/__init__.py (58%) rename pylabrobot/{ => legacy}/machines/backend.py (100%) rename pylabrobot/{ => legacy}/machines/machine.py (79%) rename pylabrobot/{ => legacy}/machines/machine_tests.py (87%) create mode 100644 pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py rename pylabrobot/{ => legacy}/microscopes/molecular_devices/pico/backend_tests.py (98%) rename pylabrobot/{ => legacy}/only_fans/__init__.py (100%) rename pylabrobot/{ => legacy}/only_fans/backend.py (54%) rename pylabrobot/{ => legacy}/only_fans/chatterbox.py (63%) create mode 100644 pylabrobot/legacy/only_fans/fan.py create mode 100644 pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py rename pylabrobot/{ => legacy}/peeling/__init__.py (100%) rename pylabrobot/{ => legacy}/peeling/backend.py (66%) create mode 100644 pylabrobot/legacy/peeling/peeler.py create mode 100644 pylabrobot/legacy/peeling/xpeel.py create mode 100644 pylabrobot/legacy/peeling/xpeel_backend.py rename pylabrobot/{ => legacy}/plate_reading/__init__.py (96%) create mode 100644 pylabrobot/legacy/plate_reading/agilent/__init__.py create mode 100644 pylabrobot/legacy/plate_reading/agilent/biotek_backend.py create mode 100644 pylabrobot/legacy/plate_reading/agilent/biotek_cytation_backend.py create mode 100644 pylabrobot/legacy/plate_reading/agilent/biotek_synergyh1_backend.py rename pylabrobot/{ => legacy}/plate_reading/backend.py (93%) rename pylabrobot/{ => legacy}/plate_reading/biotek_backend.py (100%) rename pylabrobot/{ => legacy}/plate_reading/bmg_labtech/__init__.py (100%) create mode 100644 pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py rename pylabrobot/{ => legacy}/plate_reading/byonoy/__init__.py (67%) create mode 100644 pylabrobot/legacy/plate_reading/byonoy/byonoy_a96a.py create mode 100644 pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py create mode 100644 pylabrobot/legacy/plate_reading/byonoy/byonoy_l96.py rename pylabrobot/{ => legacy}/plate_reading/byonoy/byonoy_l96a.py (51%) rename pylabrobot/{ => legacy}/plate_reading/byonoy/byonoy_tests.py (93%) create mode 100644 pylabrobot/legacy/plate_reading/chatterbox.py rename pylabrobot/{ => legacy}/plate_reading/clario_star_backend.py (100%) rename pylabrobot/{ => legacy}/plate_reading/image_reader.py (70%) create mode 100644 pylabrobot/legacy/plate_reading/imager.py rename pylabrobot/{ => legacy}/plate_reading/molecular_devices/__init__.py (90%) create mode 100644 pylabrobot/legacy/plate_reading/molecular_devices/backend.py create mode 100644 pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py rename pylabrobot/{ => legacy}/plate_reading/molecular_devices/spectramax_384_plus_backend.py (84%) rename pylabrobot/{ => legacy}/plate_reading/molecular_devices/spectramax_m5_backend.py (50%) rename pylabrobot/{ => legacy}/plate_reading/plate_reader.py (96%) rename pylabrobot/{ => legacy}/plate_reading/plate_reader_tests.py (87%) rename pylabrobot/{ => legacy}/plate_reading/standard.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/__init__.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/infinite_backend.py (99%) rename pylabrobot/{ => legacy}/plate_reading/tecan/infinite_backend_tests.py (99%) create mode 100644 pylabrobot/legacy/plate_reading/tecan/spark20m/__init__.py rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/__init__.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/base_control.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/camera_control.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/config_control.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/data_control.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/gas_control.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/injector_control.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/measurement_control.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/movement_control.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/optics_control.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/plate_transport_control.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/sensor_control.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/spark_enums.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/controls/system_control.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/enums.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/spark_backend.py (98%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/spark_backend_tests.py (90%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/spark_packet_parser.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/spark_processor.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/spark_processor_tests.py (96%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/spark_reader_async.py (100%) rename pylabrobot/{ => legacy}/plate_reading/tecan/spark20m/spark_reader_async_tests.py (95%) rename pylabrobot/{ => legacy}/plate_reading/utils.py (100%) rename pylabrobot/{ => legacy}/plate_washing/__init__.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/__init__.py (56%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/__init__.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/actions.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/actions_tests.py (98%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/backend.py (98%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/batch_tests.py (98%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/communication.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/communication_tests.py (87%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/enums.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/error_codes.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/errors.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/helpers.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/helpers_tests.py (95%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/mock_tests.py (98%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/protocol.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/queries.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/queries_tests.py (99%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/setup_tests.py (93%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/steps/__init__.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/steps/_base.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/steps/_manifold.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/steps/_peristaltic.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/steps/_shake.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/steps/_syringe.py (100%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/steps_aspirate_tests.py (97%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/steps_dispense_tests.py (98%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/steps_peristaltic_tests.py (99%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/steps_prime_tests.py (99%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/steps_shake_tests.py (97%) rename pylabrobot/{ => legacy}/plate_washing/biotek/el406/steps_wash_tests.py (99%) rename pylabrobot/{ => legacy}/powder_dispensing/__init__.py (100%) rename pylabrobot/{ => legacy}/powder_dispensing/backend.py (95%) rename pylabrobot/{ => legacy}/powder_dispensing/chatterbox.py (92%) rename pylabrobot/{ => legacy}/powder_dispensing/chemspeed/crystal_powderdose.py (90%) rename pylabrobot/{ => legacy}/powder_dispensing/powder_dispenser.py (96%) rename pylabrobot/{ => legacy}/powder_dispensing/powder_dispenser_tests.py (95%) rename pylabrobot/{ => legacy}/pumps/__init__.py (100%) rename pylabrobot/{ => legacy}/pumps/agrowpumps/__init__.py (100%) rename pylabrobot/{ => legacy}/pumps/agrowpumps/agrowdosepump_backend.py (99%) rename pylabrobot/{ => legacy}/pumps/agrowpumps/agrowdosepump_tests.py (96%) rename pylabrobot/{ => legacy}/pumps/backend.py (96%) rename pylabrobot/{ => legacy}/pumps/calibration.py (100%) rename pylabrobot/{ => legacy}/pumps/calibration_tests.py (98%) rename pylabrobot/{ => legacy}/pumps/chatterbox.py (94%) rename pylabrobot/{ => legacy}/pumps/cole_parmer/__init__.py (100%) rename pylabrobot/{ => legacy}/pumps/cole_parmer/masterflex_backend.py (97%) rename pylabrobot/{ => legacy}/pumps/errors.py (100%) rename pylabrobot/{ => legacy}/pumps/pump.py (98%) rename pylabrobot/{ => legacy}/pumps/pump_tests.py (94%) rename pylabrobot/{ => legacy}/pumps/pumparray.py (96%) create mode 100644 pylabrobot/legacy/scales/__init__.py rename pylabrobot/{ => legacy}/scales/chatterbox.py (89%) create mode 100644 pylabrobot/legacy/scales/mettler_toledo_backend.py rename pylabrobot/{ => legacy}/scales/scale.py (89%) rename pylabrobot/{ => legacy}/scales/scale_backend.py (64%) rename pylabrobot/{ => legacy}/sealing/__init__.py (100%) rename pylabrobot/{ => legacy}/sealing/a4s.py (65%) create mode 100644 pylabrobot/legacy/sealing/a4s_backend.py rename pylabrobot/{ => legacy}/sealing/backend.py (90%) create mode 100644 pylabrobot/legacy/sealing/sealer.py rename pylabrobot/{ => legacy}/shaking/__init__.py (100%) rename pylabrobot/{ => legacy}/shaking/backend.py (83%) rename pylabrobot/{ => legacy}/shaking/chatterbox.py (91%) create mode 100644 pylabrobot/legacy/shaking/shaker.py rename pylabrobot/{ => legacy}/shaking/shaker_tests.py (86%) rename pylabrobot/{ => legacy}/storage/__init__.py (100%) rename pylabrobot/{ => legacy}/storage/backend.py (95%) rename pylabrobot/{ => legacy}/storage/chatterbox.py (94%) create mode 100644 pylabrobot/legacy/storage/cytomat/__init__.py create mode 100644 pylabrobot/legacy/storage/cytomat/constants.py create mode 100644 pylabrobot/legacy/storage/cytomat/cytomat.py create mode 100644 pylabrobot/legacy/storage/cytomat/errors.py create mode 100644 pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py create mode 100644 pylabrobot/legacy/storage/cytomat/racks.py create mode 100644 pylabrobot/legacy/storage/cytomat/schemas.py create mode 100644 pylabrobot/legacy/storage/cytomat/utils.py rename pylabrobot/{ => legacy}/storage/incubator.py (99%) rename pylabrobot/{ => legacy}/storage/incubator_tests.py (86%) rename pylabrobot/{ => legacy}/storage/inheco/__init__.py (100%) rename pylabrobot/{ => legacy}/storage/inheco/incubator_shaker.py (99%) rename pylabrobot/{ => legacy}/storage/inheco/incubator_shaker_backend.py (99%) rename pylabrobot/{ => legacy}/storage/inheco/scila/__init__.py (100%) create mode 100644 pylabrobot/legacy/storage/inheco/scila/inheco_sila_interface.py create mode 100644 pylabrobot/legacy/storage/inheco/scila/scila_backend.py rename pylabrobot/{ => legacy}/storage/inheco/scila/scila_backend_tests.py (97%) create mode 100644 pylabrobot/legacy/storage/inheco/scila/soap.py rename pylabrobot/{ => legacy}/storage/liconic/__init__.py (100%) create mode 100644 pylabrobot/legacy/storage/liconic/constants.py create mode 100644 pylabrobot/legacy/storage/liconic/errors.py create mode 100644 pylabrobot/legacy/storage/liconic/liconic_backend.py rename pylabrobot/{ => legacy}/storage/liconic/liconic_backend_tests.py (98%) create mode 100644 pylabrobot/legacy/storage/liconic/racks.py rename pylabrobot/{ => legacy}/temperature_controlling/__init__.py (100%) rename pylabrobot/{ => legacy}/temperature_controlling/backend.py (83%) rename pylabrobot/{ => legacy}/temperature_controlling/chatterbox.py (93%) rename pylabrobot/{ => legacy}/temperature_controlling/inheco/__init__.py (100%) create mode 100644 pylabrobot/legacy/temperature_controlling/inheco/control_box.py rename pylabrobot/{ => legacy}/temperature_controlling/inheco/cpac.py (68%) create mode 100644 pylabrobot/legacy/temperature_controlling/inheco/cpac_backend.py create mode 100644 pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py rename pylabrobot/{ => legacy}/temperature_controlling/opentrons.py (87%) rename pylabrobot/{ => legacy}/temperature_controlling/opentrons_backend.py (96%) rename pylabrobot/{ => legacy}/temperature_controlling/opentrons_backend_usb.py (97%) create mode 100644 pylabrobot/legacy/temperature_controlling/temperature_controller.py rename pylabrobot/{ => legacy}/temperature_controlling/temperature_controller_tests.py (94%) rename pylabrobot/{ => legacy}/thermocycling/__init__.py (100%) rename pylabrobot/{ => legacy}/thermocycling/backend.py (94%) rename pylabrobot/{ => legacy}/thermocycling/chatterbox.py (97%) rename pylabrobot/{ => legacy}/thermocycling/chatterbox_tests.py (94%) rename pylabrobot/{ => legacy}/thermocycling/inheco/__init__.py (100%) rename pylabrobot/{ => legacy}/thermocycling/inheco/odtc_backend.py (98%) rename pylabrobot/{ => legacy}/thermocycling/opentrons.py (95%) rename pylabrobot/{ => legacy}/thermocycling/opentrons_backend.py (97%) rename pylabrobot/{ => legacy}/thermocycling/opentrons_backend_tests.py (80%) rename pylabrobot/{ => legacy}/thermocycling/opentrons_backend_usb.py (98%) rename pylabrobot/{ => legacy}/thermocycling/standard.py (100%) rename pylabrobot/{ => legacy}/thermocycling/thermo_fisher/__init__.py (100%) rename pylabrobot/{ => legacy}/thermocycling/thermo_fisher/atc.py (89%) rename pylabrobot/{ => legacy}/thermocycling/thermo_fisher/proflex.py (79%) rename pylabrobot/{ => legacy}/thermocycling/thermo_fisher/proflex_tests.py (98%) rename pylabrobot/{ => legacy}/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py (99%) rename pylabrobot/{ => legacy}/thermocycling/thermocycler.py (98%) rename pylabrobot/{ => legacy}/thermocycling/thermocycler_tests.py (96%) rename pylabrobot/{ => legacy}/tilting/__init__.py (63%) create mode 100644 pylabrobot/legacy/tilting/chatterbox.py create mode 100644 pylabrobot/legacy/tilting/hamilton.py create mode 100644 pylabrobot/legacy/tilting/hamilton_backend.py rename pylabrobot/{ => legacy}/tilting/tilter.py (52%) rename pylabrobot/{ => legacy}/tilting/tilter_backend.py (68%) create mode 100644 pylabrobot/liconic/__init__.py rename pylabrobot/{storage/liconic/liconic_backend.py => liconic/backend.py} (54%) rename pylabrobot/{storage => }/liconic/constants.py (67%) rename pylabrobot/{storage => }/liconic/errors.py (99%) create mode 100644 pylabrobot/liconic/liconic.py rename pylabrobot/{storage => }/liconic/racks.py (100%) create mode 100644 pylabrobot/mettler_toledo/__init__.py rename pylabrobot/{scales/mettler_toledo_backend.py => mettler_toledo/mettler_toledo.py} (85%) create mode 100644 pylabrobot/molecular_devices/__init__.py create mode 100644 pylabrobot/molecular_devices/imageXpress/pico/__init__.py rename pylabrobot/{microscopes/molecular_devices => molecular_devices/imageXpress}/pico/backend.py (86%) create mode 100644 pylabrobot/molecular_devices/imageXpress/pico/pico.py create mode 100644 pylabrobot/molecular_devices/spectramax/__init__.py rename pylabrobot/{plate_reading/molecular_devices => molecular_devices/spectramax}/backend.py (72%) rename pylabrobot/{plate_reading/molecular_devices => molecular_devices/spectramax}/backend_tests.py (87%) create mode 100644 pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py create mode 100644 pylabrobot/molecular_devices/spectramax/spectramax_m5.py delete mode 100644 pylabrobot/only_fans/hamilton_hepa_fan_backend.py delete mode 100644 pylabrobot/peeling/xpeel.py delete mode 100644 pylabrobot/plate_reading/agilent/__init__.py delete mode 100644 pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py delete mode 100644 pylabrobot/plate_reading/biotek_cytation_backend.py delete mode 100644 pylabrobot/plate_reading/biotek_synergyh1_backend.py delete mode 100644 pylabrobot/plate_reading/byonoy/byonoy_a96a.py delete mode 100644 pylabrobot/plate_reading/byonoy/byonoy_backend.py delete mode 100644 pylabrobot/plate_reading/byonoy/byonoy_l96.py delete mode 100644 pylabrobot/plate_reading/chatterbox.py delete mode 100644 pylabrobot/plate_reading/imager.py delete mode 100644 pylabrobot/plate_reading/molecular_devices_backend.py delete mode 100644 pylabrobot/plate_reading/spectramax_384_plus_backend.py delete mode 100644 pylabrobot/plate_reading/spectramax_m5_backend.py create mode 100644 pylabrobot/qinstruments/__init__.py create mode 100644 pylabrobot/qinstruments/bioshake.py delete mode 100644 pylabrobot/scales/__init__.py delete mode 100644 pylabrobot/sealing/sealer.py delete mode 100644 pylabrobot/shaking/shaker.py delete mode 100644 pylabrobot/storage/cytomat/__init__.py delete mode 100644 pylabrobot/temperature_controlling/inheco/cpac_backend.py delete mode 100644 pylabrobot/temperature_controlling/inheco/temperature_controller.py create mode 100644 pylabrobot/thermo_fisher/__init__.py create mode 100644 pylabrobot/thermo_fisher/cytomat/__init__.py rename pylabrobot/{storage/cytomat/cytomat.py => thermo_fisher/cytomat/backend.py} (78%) create mode 100644 pylabrobot/thermo_fisher/cytomat/chatterbox.py rename pylabrobot/{storage => thermo_fisher}/cytomat/constants.py (100%) create mode 100644 pylabrobot/thermo_fisher/cytomat/cytomat.py rename pylabrobot/{storage => thermo_fisher}/cytomat/errors.py (98%) rename pylabrobot/{storage/cytomat/heraeus_cytomat_backend.py => thermo_fisher/cytomat/heraeus_backend.py} (68%) rename pylabrobot/{storage => thermo_fisher}/cytomat/racks.py (100%) rename pylabrobot/{storage => thermo_fisher}/cytomat/schemas.py (92%) rename pylabrobot/{storage => thermo_fisher}/cytomat/utils.py (100%) delete mode 100644 pylabrobot/tilting/chatterbox.py delete mode 100644 pylabrobot/tilting/hamilton.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dc61754c2ce..33e206a3310 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - extra: ["", serial, usb, ftdi, hid, modbus, opentrons, sila, microscopy, pico] + extra: ["", serial, usb, ftdi, hid, modbus, opentrons, sila, cytation-microscopy, pico] name: Tests (${{ matrix.extra }}, py3.12) runs-on: ${{ matrix.os }} diff --git a/creating-capabilities.md b/creating-capabilities.md new file mode 100644 index 00000000000..c3fda8a3472 --- /dev/null +++ b/creating-capabilities.md @@ -0,0 +1,233 @@ +# Creating capabilities + +This document describes how to create new capabilities and how to migrate legacy +`Machine`/`MachineBackend` modules to the new `Device`/`Capability`/`DeviceBackend` architecture. + +## Architecture overview + +**Old (legacy):** A `Machine` owns a single `MachineBackend`. The frontend class contains all +the logic and calls backend methods directly. + +``` +Machine (frontend) + └── MachineBackend (abstract, one big interface) + └── ConcreteBackend (vendor implementation) +``` + +**New:** A `Device` owns a `DeviceBackend` and one or more `Capability` objects. Each capability +is a focused interface (e.g. shaking, temperature control) with its own backend type. Frontend +logic lives in the capability, not the device. + +``` +Device + ├── ShakerBackend (DeviceBackend subclass) + ├── ShakingCapability (owns a reference to the backend) + └── TemperatureControlCapability (owns a reference to the same backend) +``` + +### Key classes + +| Class | Location | Role | +|-------|----------|------| +| `DeviceBackend` | `pylabrobot.device` | Base for all new backends. Abstract `setup()` and `stop()`. | +| `Device` | `pylabrobot.device` | Base for all new devices. Manages capabilities lifecycle. | +| `Capability` | `pylabrobot.capabilities.capability` | Base for capabilities. Owned by a `Device`. | +| `MachineBackend` | `pylabrobot.legacy.machines.backend` | Legacy backend base. Independent from `DeviceBackend`. | +| `Machine` | `pylabrobot.legacy.machines.machine` | Legacy frontend base. | + +## Creating a new capability + +### 1. Define the backend + +Create an abstract backend in `pylabrobot/capabilities//backend.py`: + +```python +from abc import ABCMeta, abstractmethod +from pylabrobot.device import DeviceBackend + +class ShakerBackend(DeviceBackend, metaclass=ABCMeta): + @abstractmethod + async def start_shaking(self, speed: float): ... + + @abstractmethod + async def stop_shaking(self): ... +``` + +The backend defines *what* operations are possible. Keep it minimal — one capability, one concern. + +### 2. Define the capability + +Create the capability in `pylabrobot/capabilities//.py`: + +```python +from pylabrobot.capabilities.capability import Capability +from .backend import ShakerBackend + +class ShakingCapability(Capability): + def __init__(self, backend: ShakerBackend): + super().__init__(backend=backend) + self.backend: ShakerBackend = backend + + async def shake(self, speed: float, duration: float = None): + await self.backend.start_shaking(speed=speed) + if duration: + await asyncio.sleep(duration) + await self.backend.stop_shaking() +``` + +Frontend logic (validation, orchestration, convenience methods) lives here, not in the backend. + +### 3. Implement vendor backends + +In `pylabrobot//`, create a concrete backend and device: + +```python +from pylabrobot.capabilities.shaking import ShakerBackend, ShakingCapability +from pylabrobot.device import Device + +class MyVendorShakerBackend(ShakerBackend): + async def setup(self): ... + async def stop(self): ... + async def start_shaking(self, speed: float): ... + async def stop_shaking(self): ... + +class MyVendorShaker(Device): + def __init__(self, backend: MyVendorShakerBackend): + super().__init__(backend=backend) + self.shaking = ShakingCapability(backend=backend) + self._capabilities = [self.shaking] +``` + +## Making legacy code wrap new code + +When a legacy module already exists, the goal is to move the *implementation* into capabilities +while keeping the legacy frontend and backend interfaces unchanged. Users of the old API should +not need to change anything. + +### Principles + +1. **Legacy types don't change.** The old `MachineBackend` subclass keeps its name, its methods, + and its import path. Existing user code that subclasses it must keep working. + +2. **Implementation moves to capabilities.** The legacy frontend delegates to capability objects + internally. This avoids duplicating logic in both old and new code paths. + +3. **`MachineBackend` and `DeviceBackend` are independent hierarchies.** They are structurally + similar but intentionally separate. Legacy backends never inherit from `DeviceBackend`. + +4. **Always use adapters.** Even when the old and new backend signatures happen to match today, + use an adapter. This protects against silent breakage if the new capability backend changes + later. The adapter is the single point where old meets new. + +### Adapter pattern + +Every legacy frontend that delegates to a capability needs an adapter. The adapter: +- Implements the new capability backend interface (`DeviceBackend` subclass) +- Wraps a legacy backend instance and delegates to it +- Translates between old and new signatures if they differ +- Has no-op `setup()`/`stop()` since lifecycle is managed by the legacy `Machine` + +```python +# In the legacy frontend module (e.g. pylabrobot/legacy/shaking/shaker.py) + +from pylabrobot.capabilities.shaking import ShakerBackend as _NewShakerBackend, ShakingCapability + +class _ShakingAdapter(_NewShakerBackend): + """Adapts a legacy ShakerBackend to the new ShakerBackend interface.""" + def __init__(self, legacy: ShakerBackend): + self._legacy = legacy + async def setup(self): pass + async def stop(self): pass + async def start_shaking(self, speed: float): + await self._legacy.start_shaking(speed) + async def stop_shaking(self): + await self._legacy.stop_shaking() + @property + def supports_locking(self) -> bool: + return self._legacy.supports_locking + async def lock_plate(self): + await self._legacy.lock_plate() + async def unlock_plate(self): + await self._legacy.unlock_plate() + +class Shaker(Machine): + def __init__(self, backend: ShakerBackend): # legacy ShakerBackend + super().__init__(backend=backend) + self._cap = ShakingCapability(backend=_ShakingAdapter(backend)) + + async def shake(self, speed, duration=None): + await self._cap.shake(speed=speed, duration=duration) +``` + +### One-to-many split (e.g. PlateReader) + +When the old backend is a "god object" that gets split into multiple capabilities: + +``` +Old: PlateReaderBackend(MachineBackend) + read_absorbance(), read_fluorescence(), read_luminescence(), open(), close() + +New: AbsorbanceBackend(DeviceBackend) with read_absorbance() + FluorescenceBackend(DeviceBackend) with read_fluorescence() + LuminescenceBackend(DeviceBackend) with read_luminescence() +``` + +The old `PlateReaderBackend` has `read_absorbance()` but is not an `AbsorbanceBackend`. You can't +pass it directly to `AbsorbanceCapability`. Use **adapters** in the legacy frontend: + +```python +# pylabrobot/legacy/plate_reading/plate_reader.py + +class _AbsorbanceAdapter(AbsorbanceBackend): + """Adapts a legacy PlateReaderBackend to the AbsorbanceBackend interface.""" + def __init__(self, legacy: PlateReaderBackend): + self._legacy = legacy + + async def setup(self): pass # lifecycle managed by the legacy Machine + async def stop(self): pass + + async def read_absorbance(self, plate, wells, wavelength): + # translate between old and new signatures if needed + return await self._legacy.read_absorbance(plate, wells, wavelength) + + +class PlateReader(Machine): + def __init__(self, backend: PlateReaderBackend): + super().__init__(backend=backend) + self._absorbance = AbsorbanceCapability(backend=_AbsorbanceAdapter(backend)) + self._fluorescence = FluorescenceCapability(backend=_FluorescenceAdapter(backend)) + self._luminescence = LuminescenceCapability(backend=_LuminescenceAdapter(backend)) +``` + +Adapters belong in the legacy layer. They are the only place that knows about both the old and +new interfaces. If the new backend signature changes later, you update the adapter — the old +`PlateReaderBackend` interface is unaffected. + +### Case 3: Signature mismatch + +When the old and new backends have the same method name but different signatures: + +``` +Old: read_absorbance(plate, wells, wavelength) -> List[Dict] +New: read_absorbance(plate, wells, wavelength) -> List[AbsorbanceResult] +``` + +This is handled the same way as Case 2 — the adapter translates: + +```python +class _AbsorbanceAdapter(AbsorbanceBackend): + async def read_absorbance(self, plate, wells, wavelength) -> List[AbsorbanceResult]: + dicts = await self._legacy.read_absorbance(plate, wells, wavelength) + return [AbsorbanceResult(data=d["data"], wavelength=wavelength, ...) for d in dicts] +``` + +### Summary + +| Situation | Fix | +|-----------|-----| +| 1:1 mapping, same signatures | Adapter in legacy frontend (protects against future divergence) | +| 1:N split | Adapter per capability in the legacy frontend | +| Signature mismatch | Adapter that translates between old and new signatures | + +In all cases, the adapter lives in the legacy layer and is the only code that knows about both +the old and new interfaces. diff --git a/docs/user_guide/_getting-started/installation.md b/docs/user_guide/_getting-started/installation.md index 05d88c1ae58..cc7e4317e45 100644 --- a/docs/user_guide/_getting-started/installation.md +++ b/docs/user_guide/_getting-started/installation.md @@ -49,9 +49,9 @@ Different machines use different communication modes. Replace `[usb]` with one o | `hid` | hid | HID devices: e.g. Inheco Incubator/Shaker (HID mode) | | `modbus` | pymodbus | Modbus devices: e.g. Agrow Pump Array | | `opentrons` | opentrons-http-api-client | e.g. Opentrons backend | -| `microscopy` | numpy (1.26), opencv-python | e.g. Cytation imager | +| `cytation-microscopy` | numpy (1.26), opencv-python | Cytation imager | | `sila` | zeroconf, grpcio | SiLA devices | -| `pico` | microscopy + sila | ImageXpress Pico microscope | +| `pico` | opencv-python, numpy, sila | ImageXpress Pico microscope | | `dev` | All of the above + testing/linting tools | Development | Or install all dependencies: @@ -60,7 +60,7 @@ Or install all dependencies: pip install 'pylabrobot[all]' ``` -Microscopy is not included in the `all` group because it requires an older version of numpy. If you want to use microscopy features, you need to install those dependencies separately through `pip install "pylabrobot[microscopy]"`. +Cytation microscopy is not included in the `all` group because it requires an older version of numpy. If you want to use Cytation imaging features, install those dependencies separately through `pip install "pylabrobot[cytation-microscopy]"`. ### From source @@ -177,6 +177,6 @@ In order to use imaging on the Cytation, you need to: 1. Install python 3.10 2. Download Spinnaker SDK and install (including Python) [https://www.teledynevisionsolutions.com/products/spinnaker-sdk/](https://www.teledynevisionsolutions.com/products/spinnaker-sdk/) -3. Install numpy==1.26 (this is an older version) +3. Install the cytation-microscopy dependencies: `pip install "pylabrobot[cytation-microscopy]"` -If you just want to do plate reading, heating, shaknig, etc. you don't need to follow these specific steps. +If you just want to do plate reading, heating, shaking, etc. you don't need to follow these specific steps. diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py new file mode 100644 index 00000000000..b3c63105d76 --- /dev/null +++ b/pylabrobot/agilent/__init__.py @@ -0,0 +1,10 @@ +from .biotek import ( + BioTekBackend, + Cytation1, + Cytation5, + Cytation5ImagingConfig, + CytationBackend, + CytationImagingConfig, + SynergyH1, + SynergyH1Backend, +) diff --git a/pylabrobot/agilent/biotek/__init__.py b/pylabrobot/agilent/biotek/__init__.py new file mode 100644 index 00000000000..9ff9c00b02f --- /dev/null +++ b/pylabrobot/agilent/biotek/__init__.py @@ -0,0 +1,9 @@ +from .biotek import BioTekBackend +from .cytation import ( + Cytation1, + Cytation5, + Cytation5ImagingConfig, + CytationBackend, + CytationImagingConfig, +) +from .synergy_h1 import SynergyH1, SynergyH1Backend diff --git a/pylabrobot/plate_reading/agilent/biotek_backend.py b/pylabrobot/agilent/biotek/biotek.py similarity index 77% rename from pylabrobot/plate_reading/agilent/biotek_backend.py rename to pylabrobot/agilent/biotek/biotek.py index d119779ba7c..45e10b3a0f0 100644 --- a/pylabrobot/plate_reading/agilent/biotek_backend.py +++ b/pylabrobot/agilent/biotek/biotek.py @@ -2,27 +2,37 @@ import enum import logging import time +from abc import ABCMeta from typing import Dict, Iterable, List, Optional, Tuple +from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceBackend, AbsorbanceResult +from pylabrobot.capabilities.plate_reading.fluorescence import ( + FluorescenceBackend, + FluorescenceResult, +) +from pylabrobot.capabilities.plate_reading.luminescence import ( + LuminescenceBackend, + LuminescenceResult, +) from pylabrobot.io.ftdi import FTDI -from pylabrobot.plate_reading.backend import PlateReaderBackend from pylabrobot.resources import Plate, Well logger = logging.getLogger(__name__) -class BioTekPlateReaderBackend(PlateReaderBackend): +class BioTekBackend(AbsorbanceBackend, LuminescenceBackend, FluorescenceBackend, metaclass=ABCMeta): """Backend for Agilent BioTek plate readers.""" def __init__( self, timeout: float = 20, device_id: Optional[str] = None, + human_readable_device_name: str = "Agilent BioTek", ) -> None: super().__init__() self.timeout = timeout - self.io = FTDI(device_id=device_id, human_readable_device_name="Biotek Cytation 5") + self.io = FTDI(device_id=device_id, human_readable_device_name=human_readable_device_name) self._version: Optional[str] = None @@ -34,43 +44,20 @@ def _non_overlapping_rectangles( self, points: Iterable[Tuple[int, int]], ) -> List[Tuple[int, int, int, int]]: - """Find non-overlapping rectangles that cover all given points. - - Example: - >>> points = [ - >>> (1, 1), - >>> (2, 2), (2, 3), (2, 4), - >>> (3, 2), (3, 3), (3, 4), - >>> (4, 2), (4, 3), (4, 4), (4, 5), - >>> (5, 2), (5, 3), (5, 4), (5, 5), - >>> (6, 2), (6, 3), (6, 4), (6, 5), - >>> (7, 2), (7, 3), (7, 4), - >>> ] - >>> non_overlapping_rectangles(points) - [ - (1, 1, 1, 1), - (2, 2, 7, 4), - (4, 5, 6, 5), - ] - """ - + """Find non-overlapping rectangles that cover all given points.""" pts = set(points) rects = [] while pts: - # start a rectangle from one arbitrary point r0, c0 = min(pts) - # expand right c1 = c0 while (r0, c1 + 1) in pts: c1 += 1 - # expand downward as long as entire row segment is filled r1 = r0 while all((r1 + 1, c) in pts for c in range(c0, c1 + 1)): r1 += 1 rects.append((r0, c0, r1, c1)) - # remove covered points for r in range(r0, r1 + 1): for c in range(c0, c1 + 1): pts.discard((r, c)) @@ -84,17 +71,16 @@ async def setup(self) -> None: await self.io.setup() await self.io.usb_reset() await self.io.set_latency_timer(16) - await self.io.set_baudrate(9600) # 0x38 0x41 - await self.io.set_line_property(8, 2, 0) # 8 data bits, 2 stop bits, no parity + await self.io.set_baudrate(9600) + await self.io.set_line_property(8, 2, 0) SIO_RTS_CTS_HS = 0x1 << 8 await self.io.set_flowctrl(SIO_RTS_CTS_HS) await self.io.set_rts(True) - # see if we need to adjust baudrate. This appears to be the case sometimes. try: self._version = await self.get_firmware_version() except TimeoutError: - await self.io.set_baudrate(38_461) # 4e c0 + await self.io.set_baudrate(38_461) self._version = await self.get_firmware_version() self._shaking = False @@ -114,19 +100,19 @@ def version(self) -> str: return self._version @property - def abs_wavelength_range(self) -> tuple[int, int]: + def abs_wavelength_range(self) -> tuple: return (230, 999) @property - def focal_height_range(self) -> tuple[float, float]: + def focal_height_range(self) -> tuple: return (4.5, 13.88) @property - def excitation_range(self) -> tuple[int, int]: + def excitation_range(self) -> tuple: return (250, 700) @property - def emission_range(self) -> tuple[int, int]: + def emission_range(self) -> tuple: return (250, 700) @property @@ -139,22 +125,16 @@ def supports_cooling(self) -> bool: @property def temperature_range(self) -> Tuple[Optional[float], Optional[float]]: - """Return (min_temp, max_temp). - If cooling is not supported (heating only), min_temp is None. - If heating is not supported (cooling only), max_temp is None. - """ - max_temp = 45.0 if self.supports_heating else None # default BioTek max - min_temp = 4.0 if self.supports_cooling else None # default cooling minimum + max_temp = 45.0 if self.supports_heating else None + min_temp = 4.0 if self.supports_cooling else None return (min_temp, max_temp) async def _purge_buffers(self) -> None: - """Purge the RX and TX buffers, as implemented in Gen5.exe""" for _ in range(6): await self.io.usb_purge_rx_buffer() await self.io.usb_purge_tx_buffer() async def _read_until(self, terminator: bytes, timeout: Optional[float] = None) -> bytes: - """If timeout is None, use self.timeout""" if timeout is None: timeout = self.timeout x = None @@ -219,10 +199,8 @@ async def open(self, slow: bool = False): await self._set_slow_mode(slow) return await self.send_command("J") - async def close(self, plate: Optional[Plate], slow: bool = False): - # reset cache + async def close(self, plate: Optional[Plate] = None, slow: bool = False): self._plate = None - await self._set_slow_mode(slow) if plate is not None: await self.set_plate(plate) @@ -232,13 +210,11 @@ async def home(self): return await self.send_command("i", "x") async def get_current_temperature(self) -> float: - """Get current temperature in degrees Celsius.""" resp = await self.send_command("h", timeout=1) assert resp is not None return int(resp[1:-1]) / 100000 async def set_temperature(self, temperature: float): - """Set temperature in degrees Celsius.""" if not self.supports_heating and not self.supports_cooling: raise NotImplementedError(f"{self.__class__.__name__} does not support temperature control.") @@ -276,8 +252,8 @@ def _parse_body(self, body: bytes) -> List[List[Optional[float]]]: for group in grouped_values: assert len(group) == 3 - row_index = int(group[0].decode()) - 1 # 1-based index in the response - column_index = int(group[1].decode()) - 1 # 1-based index in the response + row_index = int(group[0].decode()) - 1 + column_index = int(group[1].decode()) - 1 raw_value = group[2].decode() value = float("nan") if "*" in raw_value else float(raw_value) parsed_data[(row_index, column_index)] = value @@ -290,17 +266,6 @@ def _parse_body(self, body: bytes) -> List[List[Optional[float]]]: return result async def set_plate(self, plate: Plate): - # 08120112207434014351135308559127881422 - # ^^^^ plate size z - # ^^^^^ plate size x - # ^^^^^ plate size y - # ^^^^^ bottom right x - # ^^^^^ top left x - # ^^^^^ bottom right y - # ^^^^^ top left y - # ^^ columns - # ^^ rows - if plate is self._plate: return @@ -322,8 +287,8 @@ async def set_plate(self, plate: Plate): if plate.lid is not None: plate_size_z += plate.lid.get_size_z() - plate.lid.nesting_z_height - top_left_well_center_y = plate.get_size_y() - top_left_well_center.y # invert y axis - bottom_right_well_center_y = plate.get_size_y() - bottom_right_well_center.y # invert y axis + top_left_well_center_y = plate.get_size_y() - top_left_well_center.y + bottom_right_well_center_y = plate.get_size_y() - bottom_right_well_center.y cmd = ( f"{rows:02}" @@ -344,14 +309,15 @@ async def set_plate(self, plate: Plate): def _get_min_max_row_col_tuples( self, wells: List[Well], plate: Plate - ) -> List[Tuple[int, int, int, int]]: # min_row, min_col, max_row, max_col - # check if all wells are in the same plate + ) -> List[Tuple[int, int, int, int]]: plates = set(well.parent for well in wells) if len(plates) != 1 or plates.pop() != plate: raise ValueError("All wells must be in the specified plate") return self._non_overlapping_rectangles((well.get_row(), well.get_column()) for well in wells) - async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: + async def read_absorbance( + self, plate: Plate, wells: List[Well], wavelength: int + ) -> List[AbsorbanceResult]: min_abs, max_abs = self.abs_wavelength_range if not (min_abs <= wavelength <= max_abs): raise ValueError(f"{self.__class__.__name__}: wavelength must be within {min_abs}-{max_abs}") @@ -372,34 +338,31 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int resp = await self.send_command("O") assert resp == b"\x060000\x03" - # read data body = await self._read_until(b"\x03", timeout=60 * 3) assert body is not None parsed_data = self._parse_body(body) - # Merge data for r in range(plate.num_items_y): for c in range(plate.num_items_x): if parsed_data[r][c] is not None: all_data[r][c] = parsed_data[r][c] - # Get current temperature try: temp = await self.get_current_temperature() except TimeoutError: - temp = float("nan") + temp = None return [ - { - "wavelength": wavelength, - "data": all_data, - "temperature": temp, - "time": time.time(), - } + AbsorbanceResult( + wavelength=wavelength, + data=all_data, + temperature=temp, + timestamp=time.time(), + ) ] async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 1 - ) -> List[Dict]: + ) -> List[LuminescenceResult]: min_fh, max_fh = self.focal_height_range if not (min_fh <= focal_height <= max_fh): raise ValueError(f"{self.__class__.__name__}: focal height must be within {min_fh}-{max_fh}") @@ -412,8 +375,6 @@ async def read_luminescence( integration_time_seconds = int(integration_time) assert 0 <= integration_time_seconds <= 60, "Integration time seconds must be between 0 and 60" integration_time_milliseconds = integration_time - int(integration_time) - # TODO: I don't know if the multiple of 0.2 is a firmware requirement, but it's what gen5.exe requires. - # round because of floating point precision issues assert round(integration_time_milliseconds * 10) % 2 == 0, ( "Integration time milliseconds must be a multiple of 0.2" ) @@ -424,38 +385,34 @@ async def read_luminescence( [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) ] for min_row, min_col, max_row, max_col in self._get_min_max_row_col_tuples(wells, plate): - cmd = f"008401{min_row + 1:02}{min_col + 1:02}{max_row + 1:02}{max_col + 1:02}000120010000110010000012300{integration_time_seconds_s}{integration_time_milliseconds_s}200200-001000-003000000000000000000013510" # 0812 - checksum = str((sum(cmd.encode()) + 8) % 100).zfill(2) # don't know why +8 + cmd = f"008401{min_row + 1:02}{min_col + 1:02}{max_row + 1:02}{max_col + 1:02}000120010000110010000012300{integration_time_seconds_s}{integration_time_milliseconds_s}200200-001000-003000000000000000000013510" + checksum = str((sum(cmd.encode()) + 8) % 100).zfill(2) cmd = cmd + checksum await self.send_command("D", cmd) resp = await self.send_command("O") assert resp == b"\x060000\x03" - # 2m10s of reading per 1 second of integration time - # allow 60 seconds flat timeout = 60 + integration_time_seconds * (2 * 60 + 10) body = await self._read_until(b"\x03", timeout=timeout) assert body is not None parsed_data = self._parse_body(body) - # Merge data for r in range(plate.num_items_y): for c in range(plate.num_items_x): if parsed_data[r][c] is not None: all_data[r][c] = parsed_data[r][c] - # Get current temperature try: temp = await self.get_current_temperature() except TimeoutError: - temp = float("nan") + temp = None return [ - { - "data": all_data, - "temperature": temp, - "time": time.time(), - } + LuminescenceResult( + data=all_data, + temperature=temp, + timestamp=time.time(), + ) ] async def read_fluorescence( @@ -465,7 +422,7 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, - ) -> List[Dict]: + ) -> List[FluorescenceResult]: min_fh, max_fh = self.focal_height_range if not (min_fh <= focal_height <= max_fh): raise ValueError(f"{self.__class__.__name__}: focal height must be within {min_fh}-{max_fh}") @@ -496,7 +453,7 @@ async def read_fluorescence( f"008401{min_row + 1:02}{min_col + 1:02}{max_row + 1:02}{max_col + 1:02}0001200100001100100000135000100200200{excitation_wavelength_str}000" f"{emission_wavelength_str}000000000000000000210011" ) - checksum = str((sum(cmd.encode()) + 7) % 100).zfill(2) # don't know why +7 + checksum = str((sum(cmd.encode()) + 7) % 100).zfill(2) cmd = cmd + checksum + "\x03" await self.send_command("D", cmd) @@ -506,26 +463,24 @@ async def read_fluorescence( body = await self._read_until(b"\x03", timeout=60 * 2) assert body is not None parsed_data = self._parse_body(body) - # Merge data for r in range(plate.num_items_y): for c in range(plate.num_items_x): if parsed_data[r][c] is not None: all_data[r][c] = parsed_data[r][c] - # Get current temperature try: temp = await self.get_current_temperature() except TimeoutError: - temp = float("nan") + temp = None return [ - { - "ex_wavelength": excitation_wavelength, - "em_wavelength": emission_wavelength, - "data": all_data, - "temperature": temp, - "time": time.time(), - } + FluorescenceResult( + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + data=all_data, + temperature=temp, + timestamp=time.time(), + ) ] async def _abort(self) -> None: @@ -536,27 +491,22 @@ class ShakeType(enum.IntEnum): ORBITAL = 1 async def shake(self, shake_type: ShakeType, frequency: int) -> None: - """Warning: the duration for shaking has to be specified on the machine, and the maximum is - 16 minutes. As a hack, we start shaking for the maximum duration every time as long as stop - is not called. I think the machine might open the door at the end of the 16 minutes and then - move it back in. We have to find a way to shake continuously, which is possible in protocol-mode - with kinetics. + """Start continuous shaking. Args: - frequency: speed, in mm. 360 CPM = 6mm; 410 CPM = 5mm; 493 CPM = 4mm; 567 CPM = 3mm; 731 CPM = 2mm; 1096 CPM = 1mm + frequency: speed, in mm. 360 CPM = 6mm; 410 CPM = 5mm; 493 CPM = 4mm; + 567 CPM = 3mm; 731 CPM = 2mm; 1096 CPM = 1mm """ - max_duration = 16 * 60 # 16 minutes + max_duration = 16 * 60 self._shaking_started = asyncio.Event() async def shake_maximal_duration(): - """This method will start the shaking, but returns immediately after - shaking has started.""" shake_type_bit = str(shake_type.value) duration = str(max_duration).zfill(3) assert 1 <= frequency <= 6, "Frequency must be between 1 and 6" cmd = f"0033010101010100002000000013{duration}{shake_type_bit}{frequency}01" - checksum = str((sum(cmd.encode()) + 73) % 100).zfill(2) # don't know why +73 + checksum = str((sum(cmd.encode()) + 73) % 100).zfill(2) cmd = cmd + checksum + "\x03" await self.send_command("D", cmd) @@ -570,7 +520,6 @@ async def shake_continuous(): while self._shaking: await shake_maximal_duration() - # short sleep allows = frequent checks for fast stopping seconds_since_start: float = 0 loop_wait_time = 0.25 while seconds_since_start < max_duration and self._shaking: @@ -591,6 +540,5 @@ async def stop_shaking(self) -> None: try: await self._shaking_task except asyncio.CancelledError: - # Task cancellation is expected here; safe to ignore this exception. pass self._shaking_task = None diff --git a/pylabrobot/plate_reading/agilent/biotek_tests.py b/pylabrobot/agilent/biotek/biotek_tests.py similarity index 94% rename from pylabrobot/plate_reading/agilent/biotek_tests.py rename to pylabrobot/agilent/biotek/biotek_tests.py index d011901249f..2e3c5df38e2 100644 --- a/pylabrobot/plate_reading/agilent/biotek_tests.py +++ b/pylabrobot/agilent/biotek/biotek_tests.py @@ -10,7 +10,7 @@ pytest.importorskip("pylibftdi") -from pylabrobot.plate_reading.agilent.biotek_cytation_backend import CytationBackend +from pylabrobot.agilent.biotek.cytation import CytationBackend from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb, CellVis_96_wellplate_350uL_Fb @@ -40,8 +40,9 @@ async def asyncSetUp(self): self.plate = CellVis_24_wellplate_3600uL_Fb(name="plate") # Mock time.time() to control the timestamp in the results - self.mock_time = unittest.mock.patch("time.time", return_value=12345.6789).start() - self.addCleanup(self.mock_time.stop) + self._time_patcher = unittest.mock.patch("time.time", return_value=12345.6789) + self.mock_time = self._time_patcher.start() + self.addCleanup(self._time_patcher.stop) async def test_setup(self): self.backend.io.read.side_effect = _byte_iter("\x061650200 Version 1.04 0000\x03") @@ -192,17 +193,11 @@ async def test_read_absorbance(self): [0.1255, 0.0742, 0.0747, 0.0694, 0.1004, 0.09, 0.0659, 0.0858, 0.0876, 0.0815, 0.098, 0.1329], [0.1427, 0.1174, 0.0684, 0.0657, 0.0732, 0.067, 0.0602, 0.079, 0.0667, 0.1103, 0.129, 0.1316], ] - self.assertEqual( - resp, - [ - { - "wavelength": 580, - "data": expected_data, - "temperature": 23.6, - "time": 12345.6789, - } - ], - ) + self.assertEqual(len(resp), 1) + self.assertEqual(resp[0].wavelength, 580) + self.assertEqual(resp[0].data, expected_data) + self.assertEqual(resp[0].temperature, 23.6) + self.assertEqual(resp[0].timestamp, 12345.6789) async def test_read_luminescence_partial(self): self.backend.io.read.side_effect = _byte_iter( @@ -258,16 +253,10 @@ async def test_read_luminescence_partial(self): [0.0, 10.0, 9.0, None, None, None, None, None, None, None, None, None], [None, None, None, None, None, None, None, None, None, None, None, None], ] - self.assertEqual( - resp, - [ - { - "data": expected_data, - "temperature": 23.6, - "time": 12345.6789, - } - ], - ) + self.assertEqual(len(resp), 1) + self.assertEqual(resp[0].data, expected_data) + self.assertEqual(resp[0].temperature, 23.6) + self.assertEqual(resp[0].timestamp, 12345.6789) async def test_read_fluorescence(self): self.backend.io.read.side_effect = _byte_iter( @@ -328,18 +317,12 @@ async def test_read_fluorescence(self): [653.0, 783.0, 522.0, 536.0, 673.0, 858.0, 526.0, 627.0, 574.0, 1993.0, 712.0, 970.0], [1118.0, 742.0, 542.0, 555.0, 622.0, 688.0, 542.0, 697.0, 900.0, 3002.0, 607.0, 523.0], ] - self.assertEqual( - resp, - [ - { - "ex_wavelength": 485, - "em_wavelength": 528, - "data": expected_data, - "temperature": 23.6, - "time": 12345.6789, - } - ], - ) + self.assertEqual(len(resp), 1) + self.assertEqual(resp[0].excitation_wavelength, 485) + self.assertEqual(resp[0].emission_wavelength, 528) + self.assertEqual(resp[0].data, expected_data) + self.assertEqual(resp[0].temperature, 23.6) + self.assertEqual(resp[0].timestamp, 12345.6789) async def test_parse_body_asterisks_as_nan(self): """Unmeasured wells return ******* which should be parsed as NaN.""" diff --git a/pylabrobot/plate_reading/agilent/biotek_cytation_backend.py b/pylabrobot/agilent/biotek/cytation.py similarity index 81% rename from pylabrobot/plate_reading/agilent/biotek_cytation_backend.py rename to pylabrobot/agilent/biotek/cytation.py index 67d87b3f3b3..effa7538d49 100644 --- a/pylabrobot/plate_reading/agilent/biotek_cytation_backend.py +++ b/pylabrobot/agilent/biotek/cytation.py @@ -4,24 +4,15 @@ import math import re import time -import warnings from dataclasses import dataclass from typing import List, Literal, Optional, Tuple, Union -from pylabrobot.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend -from pylabrobot.plate_reading.backend import ImagerBackend -from pylabrobot.resources import Plate - -try: - import PySpin # type: ignore - - # can be downloaded from https://www.teledynevisionsolutions.com/products/spinnaker-sdk/ - USE_PYSPIN = True -except ImportError as e: - USE_PYSPIN = False - _PYSPIN_IMPORT_ERROR = e - -from pylabrobot.plate_reading.standard import ( +from pylabrobot.agilent.biotek.biotek import BioTekBackend +from pylabrobot.capabilities.microscopy import ( + MicroscopyBackend, + MicroscopyCapability, +) +from pylabrobot.capabilities.microscopy.standard import ( Exposure, FocalPosition, Gain, @@ -30,6 +21,19 @@ ImagingResult, Objective, ) +from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability +from pylabrobot.capabilities.plate_reading.fluorescence import FluorescenceCapability +from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, Plate, PlateHolder, Resource + +try: + import PySpin # type: ignore + + USE_PYSPIN = True +except ImportError as e: + USE_PYSPIN = False + _PYSPIN_IMPORT_ERROR = e SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR = ( PySpin.SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR if USE_PYSPIN else -1 @@ -44,14 +48,11 @@ class CytationImagingConfig: camera_serial_number: Optional[str] = None max_image_read_attempts: int = 50 - - # if not specified, these will be loaded from machine configuration (register with gen5.exe) objectives: Optional[List[Optional[Objective]]] = None filters: Optional[List[Optional[ImagingMode]]] = None def retry(func, *args, **kwargs): - """Call func with retries and logging.""" max_tries = 10 delay = 0.1 tries = 0 @@ -62,21 +63,17 @@ def retry(func, *args, **kwargs): tries += 1 if tries >= max_tries: raise RuntimeError(f"Failed after {max_tries} tries") from ex - logger.warning( - "Retry %d/%d failed: %s", - tries, - max_tries, - str(ex), - ) + logger.warning("Retry %d/%d failed: %s", tries, max_tries, str(ex)) time.sleep(delay) -class CytationBackend(BioTekPlateReaderBackend, ImagerBackend): - """Backend for Agilent BioTek Cytation plate readers. +# --------------------------------------------------------------------------- +# Backend +# --------------------------------------------------------------------------- - The camera is interfaced using the Spinnaker SDK, and the camera used during development is the - Point Grey Research Inc. Blackfly BFLY-U3-23S6M. This uses a Sony IMX249 sensor. - """ + +class CytationBackend(BioTekBackend, MicroscopyBackend): + """Backend for Agilent BioTek Cytation plate readers with imaging.""" def __init__( self, @@ -84,7 +81,9 @@ def __init__( device_id: Optional[str] = None, imaging_config: Optional[CytationImagingConfig] = None, ) -> None: - super().__init__(timeout=timeout, device_id=device_id) + super().__init__( + timeout=timeout, device_id=device_id, human_readable_device_name="Agilent BioTek Cytation" + ) self._spinnaker_system: Optional["PySpin.SystemPtr"] = None self._cam: Optional["PySpin.CameraPtr"] = None @@ -94,7 +93,7 @@ def __init__( self._exposure: Optional[Exposure] = None self._focal_height: Optional[FocalPosition] = None self._gain: Optional[Gain] = None - self._imaging_mode: Optional["ImagingMode"] = None + self._imaging_mode: Optional[ImagingMode] = None self._row: Optional[int] = None self._column: Optional[int] = None self._pos_x: Optional[float] = None @@ -111,10 +110,6 @@ async def setup(self, use_cam: bool = False) -> None: try: await self._set_up_camera() except: - # if setting up the camera fails, we have to close the ftdi connection - # so that the user can try calling setup() again. - # if we don't close the ftdi connection here, it will be open until the - # python kernel is restarted. try: await self.stop() except Exception: @@ -170,7 +165,6 @@ async def _set_up_camera(self) -> None: logger.debug(f"{self.__class__.__name__} setting up camera") - # -- Retrieve singleton reference to system object (Spinnaker) -- self._spinnaker_system = PySpin.System.GetInstance() version = self._spinnaker_system.GetLibraryVersion() logger.debug( @@ -181,7 +175,6 @@ async def _set_up_camera(self) -> None: version.build, ) - # -- Get the camera by serial number, or the first. -- cam_list = self._spinnaker_system.GetCameras() num_cameras = cam_list.GetSize() logger.debug(f"{self.__class__.__name__} number of cameras detected: %d", num_cameras) @@ -198,7 +191,7 @@ async def _set_up_camera(self) -> None: self._cam = cam logger.info(f"{self.__class__.__name__} using camera with serial number %s", serial_number) break - else: # if no specific camera was found by serial number so use the first one + else: if num_cameras > 0: self._cam = cam_list.GetByIndex(0) logger.info( @@ -212,18 +205,16 @@ async def _set_up_camera(self) -> None: if self._cam is None: raise RuntimeError( - f"{self.__class__.__name__}: No camera found. Make sure the camera is connected and the serial " - "number is correct." + f"{self.__class__.__name__}: No camera found. Make sure the camera is connected and the " + "serial number is correct." ) - # -- Initialize camera -- for _ in range(10): try: - self._cam.Init() # SpinnakerException: Spinnaker: Could not read the XML URL [-1010] + self._cam.Init() break except: # noqa await asyncio.sleep(0.1) - pass else: raise RuntimeError( "Failed to initialize camera. Make sure the camera is connected and the " @@ -231,45 +222,36 @@ async def _set_up_camera(self) -> None: ) nodemap = self._cam.GetNodeMap() - # -- Configure trigger to be software -- - # This is needed for longer exposure times (otherwise 27.8ms is the maximum) - # 1. Set trigger selector to frame start + # Configure software trigger ptr_trigger_selector = PySpin.CEnumerationPtr(nodemap.GetNode("TriggerSelector")) if not PySpin.IsReadable(ptr_trigger_selector) or not PySpin.IsWritable(ptr_trigger_selector): - raise RuntimeError( - "unable to configure TriggerSelector (can't read or write TriggerSelector)" - ) + raise RuntimeError("unable to configure TriggerSelector") ptr_frame_start = PySpin.CEnumEntryPtr(ptr_trigger_selector.GetEntryByName("FrameStart")) if not PySpin.IsReadable(ptr_frame_start): raise RuntimeError("unable to configure TriggerSelector (can't read FrameStart)") ptr_trigger_selector.SetIntValue(int(ptr_frame_start.GetNumericValue())) - # 2. Set trigger source to software ptr_trigger_source = PySpin.CEnumerationPtr(nodemap.GetNode("TriggerSource")) if not PySpin.IsReadable(ptr_trigger_source) or not PySpin.IsWritable(ptr_trigger_source): - raise RuntimeError("unable to configure TriggerSource (can't read or write TriggerSource)") + raise RuntimeError("unable to configure TriggerSource") ptr_inference_ready = PySpin.CEnumEntryPtr(ptr_trigger_source.GetEntryByName("Software")) if not PySpin.IsReadable(ptr_inference_ready): raise RuntimeError("unable to configure TriggerSource (can't read Software)") ptr_trigger_source.SetIntValue(int(ptr_inference_ready.GetNumericValue())) - # 3. Set trigger mode to on ptr_trigger_mode = PySpin.CEnumerationPtr(nodemap.GetNode("TriggerMode")) if not PySpin.IsReadable(ptr_trigger_mode) or not PySpin.IsWritable(ptr_trigger_mode): - raise RuntimeError("unable to configure TriggerMode (can't read or write TriggerMode)") + raise RuntimeError("unable to configure TriggerMode") ptr_trigger_on = PySpin.CEnumEntryPtr(ptr_trigger_mode.GetEntryByName("On")) if not PySpin.IsReadable(ptr_trigger_on): raise RuntimeError("unable to query TriggerMode On") ptr_trigger_mode.SetIntValue(int(ptr_trigger_on.GetNumericValue())) - # "NOTE: Blackfly and Flea3 GEV cameras need 1 second delay after trigger mode is turned on" + # Blackfly/Flea3 GEV cameras need 1 second delay after trigger mode on await asyncio.sleep(1) - # -- Load filter information -- if self._filters is None: await self._load_filters() - - # -- Load objective information -- if self._objectives is None: await self._load_objectives() @@ -312,7 +294,7 @@ async def _load_filters(self): 1225101: ImagingMode.GFP, 1225116: ImagingMode.GFP_CY5, 1225122: ImagingMode.OXIDIZED_ROGFP2, - 1225111: ImagingMode.PROPOIDIUM_IODIDE, + 1225111: ImagingMode.PROPIDIUM_IODIDE, 1225103: ImagingMode.RFP, 1225117: ImagingMode.RFP_CY5, 1225115: ImagingMode.TAG_BFP, @@ -329,7 +311,7 @@ async def _load_objectives(self): if self.version.startswith("1"): for spot in [1, 2]: configuration = await self.send_command("i", f"o{spot}") - weird_encoding = { # ? + weird_encoding = { 0x00: " ", 0x14: ".", 0x15: "/", @@ -372,9 +354,7 @@ async def _load_objectives(self): } if configuration is None: raise RuntimeError("Failed to load objective configuration") - # TODO: loading when no objective is set. I believe it's four 0s. middle_part = re.split(r"\s+", configuration.rstrip(b"\x03").decode("utf-8"))[1] - # not the real part number, but it's what's used in the xml files. eg "UPLFLN" if middle_part == "0000": self._objectives.append(None) else: @@ -406,7 +386,6 @@ async def _load_objectives(self): self._objectives.append(part_number2objective[part_number.lower()]) elif self.version.startswith("2"): for spot in range(1, 7): - # +1 for some reason, eg first is h2 configuration = await self.send_command("i", f"h{spot + 1}") assert configuration is not None if configuration.startswith(b"****"): @@ -426,9 +405,7 @@ def _stop_camera(self) -> None: if self._cam is not None: if self._acquiring: self.stop_acquisition() - self._reset_trigger() - self._cam.DeInit() self._cam = None if self._spinnaker_system is not None: @@ -437,54 +414,24 @@ def _stop_camera(self) -> None: def _reset_trigger(self): if self._cam is None: return - - # adopted from example try: nodemap = self._cam.GetNodeMap() node_trigger_mode = PySpin.CEnumerationPtr(nodemap.GetNode("TriggerMode")) if not PySpin.IsReadable(node_trigger_mode) or not PySpin.IsWritable(node_trigger_mode): return - node_trigger_mode_off = node_trigger_mode.GetEntryByName("Off") if not PySpin.IsReadable(node_trigger_mode_off): return - node_trigger_mode.SetIntValue(node_trigger_mode_off.GetValue()) except PySpin.SpinnakerException: pass def _get_device_info(self, cam): - """Get device info for cameras.""" - # should have keys: - # - DeviceID - # - DeviceSerialNumber - # - DeviceUserID - # - DeviceVendorName - # - DeviceModelName - # - DeviceVersion - # - DeviceBootloaderVersion - # - DeviceType - # - DeviceDisplayName - # - DeviceAccessStatus - # - DeviceDriverVersion - # - DeviceIsUpdater - # - DeviceInstanceId - # - DeviceLocation - # - DeviceCurrentSpeed - # - DeviceU3VProtocol - # - DevicePortId - # - GenICamXMLLocation - # - GenICamXMLPath - # - GUIXMLLocation - # - GUIXMLPath - device_info = {} - nodemap = cam.GetTLDeviceNodeMap() node_device_information = PySpin.CCategoryPtr(nodemap.GetNode("DeviceInformation")) if not PySpin.IsReadable(node_device_information): raise RuntimeError("Device control information not readable.") - features = node_device_information.GetFeatures() for feature in features: node_feature = PySpin.CValuePtr(feature) @@ -498,10 +445,9 @@ def _get_device_info(self, cam): f"Error: {str(e)}" ) from e device_info[node_feature_name] = node_feature_value - return device_info - async def close(self, plate: Optional[Plate], slow: bool = False): + async def close(self, plate: Optional[Plate] = None, slow: bool = False): await super().close(plate, slow) self._clear_imaging_state() @@ -534,8 +480,6 @@ async def led_off(self): await self.send_command("i", "L0001") async def set_focus(self, focal_position: FocalPosition): - """focus position in mm""" - if focal_position == "machine-auto": raise ValueError( "focal_position cannot be 'machine-auto'. Use the PLR Imager universal autofocus instead." @@ -545,9 +489,6 @@ async def set_focus(self, focal_position: FocalPosition): logger.debug("Focus position is already set to %s", focal_position) return - # There is a difference between the number in the program and the number sent to the machine, - # which is modelled using the following linear relation. R^2=0.999999999 - # convert from mm to um slope, intercept = (10.637991436186072, 1.0243013203461762) focus_integer = int(focal_position + intercept + slope * focal_position * 1000) focus_str = str(focus_integer).zfill(5) @@ -560,11 +501,6 @@ async def set_focus(self, focal_position: FocalPosition): self._focal_height = focal_position async def set_position(self, x: float, y: float): - """ - Args: - x: in mm from the center of the selected well - y: in mm from the center of the selected well - """ if self._imaging_mode is None: raise ValueError("Imaging mode not set. Run set_imaging_mode() first.") @@ -572,7 +508,6 @@ async def set_position(self, x: float, y: float): logger.debug("Position is already set to (%s, %s)", x, y) return - # firmware is in (10/0.984 (10/0.984))um units. plr is mm. To convert x_str, y_str = ( str(round(x * 100 * 0.984)).zfill(6), str(round(y * 100 * 0.984)).zfill(6), @@ -620,8 +555,6 @@ async def set_auto_exposure(self, auto_exposure: Literal["off", "once", "continu ) async def set_exposure(self, exposure: Exposure): - """exposure (integration time) in ms, or "machine-auto" """ - if exposure == self._exposure: logger.debug("Exposure time is already set to %s", exposure) return @@ -629,7 +562,6 @@ async def set_exposure(self, exposure: Exposure): if self._cam is None: raise ValueError("Camera not initialized. Run setup(use_cam=True) first.") - # either set auto exposure to continuous, or turn off if isinstance(exposure, str): if exposure == "machine-auto": await self.set_auto_exposure("continuous") @@ -638,7 +570,6 @@ async def set_exposure(self, exposure: Exposure): raise ValueError("exposure must be a number or 'auto'") retry(self._cam.ExposureAuto.SetValue, PySpin.ExposureAuto_Off) - # set exposure time (in microseconds) if self._cam.ExposureTime.GetAccessMode() != PySpin.RW: raise RuntimeError("unable to write ExposureTime") exposure_us = int(exposure * 1000) @@ -662,7 +593,6 @@ async def select(self, row: int, column: int): await self.set_position(0, 0) async def set_gain(self, gain: Gain): - """gain of unknown units, or "machine-auto" """ if self._cam is None: raise ValueError("Camera not initialized. Run setup(use_cam=True) first.") @@ -675,7 +605,6 @@ async def set_gain(self, gain: Gain): nodemap = self._cam.GetNodeMap() - # set/disable automatic gain node_gain_auto = PySpin.CEnumerationPtr(nodemap.GetNode("GainAuto")) if not PySpin.IsReadable(node_gain_auto) or not PySpin.IsWritable(node_gain_auto): raise RuntimeError("unable to set automatic gain") @@ -742,8 +671,6 @@ async def set_imaging_mode(self, mode: ImagingMode, led_intensity: int): return if mode == ImagingMode.COLOR_BRIGHTFIELD: - # color brightfield will quickly switch through different filters, 05, 06, 07, 08 - # it sometimes calls (i, l{4,5,6,7}) before switching to the next filter. unclear. raise NotImplementedError("Color brightfield imaging not implemented yet") await self.led_off() @@ -776,7 +703,6 @@ async def set_imaging_mode(self, mode: ImagingMode, led_intensity: int): await self.send_command("Y", f"P0d{filter_index:02}") await self.send_command("Y", "P1001") - # Turn led on in the new mode self._imaging_mode = mode await self.led_on(intensity=led_intensity) @@ -787,8 +713,7 @@ async def _acquire_image( ) -> Image: assert self._cam is not None nodemap = self._cam.GetNodeMap() - - assert self.imaging_config is not None, "Need to set imaging_config first" + assert self.imaging_config is not None num_tries = 0 while num_tries < self.imaging_config.max_image_read_attempts: @@ -798,7 +723,7 @@ async def _acquire_image( try: node_softwaretrigger_cmd.Execute() - timeout = int(self._cam.ExposureTime.GetValue() / 1000 + 1000) # from example + timeout = int(self._cam.ExposureTime.GetValue() / 1000 + 1000) image_result = self._cam.GetNextImage(timeout) if not image_result.IsIncomplete(): processor = PySpin.ImageProcessor() @@ -807,13 +732,13 @@ async def _acquire_image( image_result.Release() return image_converted.GetNDArray() # type: ignore except SpinnakerException as e: - # the image is not ready yet, try again logger.warning("Failed to get image: %s", e) self.stop_acquisition() self.start_acquisition() if "[-1011]" in str(e): logger.warning( - "[-1011] error might occur when the camera is plugged into a USB hub that does not have enough throughput." + "[-1011] error might occur when the camera is plugged into a USB hub " + "that does not have enough throughput." ) num_tries += 1 @@ -838,23 +763,6 @@ async def capture( pixel_format: int = PixelFormat_Mono8, auto_stop_acquisition=True, ) -> ImagingResult: - """Capture image using the microscope - - speed: 211 ms ± 331 μs per loop (mean ± std. dev. of 7 runs, 10 loops each) - - Args: - exposure_time: exposure time in ms, or `"machine-auto"` - focal_height: focal height in mm, or `"machine-auto"` - coverage: coverage of the well, either `"full"` or a tuple of `(num_rows, num_columns)`. - Around `center_position`. - center_position: center position of the well, in mm from the center of the selected well. If - `None`, the center of the selected well is used (eg (0, 0) offset). If `coverage` is - specified, this is the center of the coverage area. - color_processing_algorithm: color processing algorithm. See - PySpin.SPINNAKER_COLOR_PROCESSING_ALGORITHM_* - pixel_format: pixel format. See PySpin.PixelFormat_* - """ - assert overlap is None, "not implemented yet" if self._cam is None: @@ -874,9 +782,6 @@ async def capture( await self.set_focus(focal_height) def image_size(magnification: float) -> Tuple[float, float]: - # "wide fov" is an option in gen5.exe, but in reality it takes the same pictures. So we just - # simply take the wide fov option. - # um to mm (plr unit) if magnification == 4: return (3474 / 1000, 3474 / 1000) if magnification == 20: @@ -899,10 +804,8 @@ def image_size(magnification: float) -> Tuple[float, float]: ) rows, cols = coverage - # Get positions, centered around enter_position if center_position is None: center_position = (0, 0) - # Going in a snake pattern is not faster (strangely) positions = [ (x * img_width + center_position[0], -y * img_height + center_position[1]) for y in [i - (rows - 1) / 2 for i in range(rows)] @@ -919,35 +822,119 @@ def image_size(magnification: float) -> Tuple[float, float]: ) ) t1 = time.time() - logger.debug( - "[cytation5] acquired image in %.2f seconds at position", - t1 - t0, - ) + logger.debug("[cytation] acquired image in %.2f seconds", t1 - t0) finally: await self.led_off() if auto_stop_acquisition: self.stop_acquisition() exposure_ms = float(self._cam.ExposureTime.GetValue()) / 1000 - assert self._focal_height is not None, "Focal height not set. Run set_focus() first." + assert self._focal_height is not None focal_height_val = float(self._focal_height) return ImagingResult(images=images, exposure_time=exposure_ms, focal_height=focal_height_val) -class Cytation5ImagingConfig(CytationImagingConfig): - def __init__(self, *args, **kwargs): - warnings.warn( - "`Cytation5ImagingConfig` is deprecated. Please use `CytationImagingConfig` instead. ", - FutureWarning, +# --------------------------------------------------------------------------- +# Devices +# --------------------------------------------------------------------------- + + +class Cytation5(Resource, Device): + """Agilent BioTek Cytation 5 — plate reader + imager.""" + + def __init__( + self, + name: str, + device_id: Optional[str] = None, + imaging_config: Optional[CytationImagingConfig] = None, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + backend = CytationBackend(device_id=device_id, imaging_config=imaging_config) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Agilent BioTek Cytation 5", + ) + Device.__init__(self, backend=backend) + self._backend: CytationBackend = backend + self.absorbance = AbsorbanceCapability(backend=backend) + self.luminescence = LuminescenceCapability(backend=backend) + self.fluorescence = FluorescenceCapability(backend=backend) + self.microscopy = MicroscopyCapability(backend=backend) + self._capabilities = [self.absorbance, self.luminescence, self.fluorescence, self.microscopy] + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, + size_y=85.48, + size_z=0, # TODO: measure + pedestal_size_z=0, + child_location=Coordinate.zero(), # TODO: measure ) - super().__init__(*args, **kwargs) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + async def open(self, slow: bool = False) -> None: + await self._backend.open(slow=slow) + async def close(self, slow: bool = False) -> None: + await self._backend.close(slow=slow) -class Cytation5Backend(CytationBackend): - def __init__(self, *args, **kwargs): - warnings.warn( - "`Cytation5Backend` is deprecated. Please use `CytationBackend` instead. ", - FutureWarning, + +class Cytation1(Resource, Device): + """Agilent BioTek Cytation 1 — plate reader only (no imager).""" + + def __init__( + self, + name: str, + device_id: Optional[str] = None, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + backend = BioTekBackend(device_id=device_id) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Agilent BioTek Cytation 1", + ) + Device.__init__(self, backend=backend) + self._backend: BioTekBackend = backend + self.absorbance = AbsorbanceCapability(backend=backend) + self.luminescence = LuminescenceCapability(backend=backend) + self.fluorescence = FluorescenceCapability(backend=backend) + self._capabilities = [self.absorbance, self.luminescence, self.fluorescence] + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, + size_y=85.48, + size_z=0, # TODO: measure + pedestal_size_z=0, + child_location=Coordinate.zero(), # TODO: measure ) - super().__init__(*args, **kwargs) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + async def open(self, slow: bool = False) -> None: + await self._backend.open(slow=slow) + + async def close(self, slow: bool = False) -> None: + await self._backend.close(slow=slow) + + +# Deprecated aliases +Cytation5ImagingConfig = CytationImagingConfig diff --git a/pylabrobot/agilent/biotek/synergy_h1.py b/pylabrobot/agilent/biotek/synergy_h1.py new file mode 100644 index 00000000000..97e8a4b1d21 --- /dev/null +++ b/pylabrobot/agilent/biotek/synergy_h1.py @@ -0,0 +1,150 @@ +import asyncio +import logging +import time +from typing import Optional + +try: + from pylibftdi import FtdiError + + HAS_PYLIBFTDI = True +except ImportError: + HAS_PYLIBFTDI = False + FtdiError = Exception # type: ignore[misc,assignment] + +from pylabrobot.agilent.biotek.biotek import BioTekBackend +from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability +from pylabrobot.capabilities.plate_reading.fluorescence import FluorescenceCapability +from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, PlateHolder, Resource + +logger = logging.getLogger(__name__) + + +class SynergyH1Backend(BioTekBackend): + """Backend for Agilent BioTek Synergy H1 plate readers.""" + + def __init__(self, timeout: float = 20, device_id: Optional[str] = None) -> None: + super().__init__( + timeout=timeout, device_id=device_id, human_readable_device_name="Agilent BioTek Synergy H1" + ) + + @property + def supports_heating(self): + return True + + @property + def supports_cooling(self): + return False + + @property + def focal_height_range(self): + return (4.5, 10.68) + + async def _read_until( + self, terminator: bytes, timeout: Optional[float] = None, chunk_size: int = 512 + ) -> bytes: + if timeout is None: + timeout = self.timeout + + deadline = time.time() + timeout + buf = bytearray() + + retries = 0 + max_retries = 3 + + while True: + if time.time() > deadline: + logger.debug( + f"{self.__class__.__name__} _read_until timed out; partial buffer (hex): %s", buf.hex() + ) + raise TimeoutError( + f"{self.__class__.__name__} _read_until timed out waiting for {terminator!r}; partial={buf.hex()}" + ) + + try: + data = await self.io.read(chunk_size) + if len(data) == 0: + await asyncio.sleep(0.02) + continue + + buf.extend(data) + + if terminator in buf: + idx = buf.index(terminator) + len(terminator) + full = bytes(buf[:idx]) + logger.debug( + f"{self.__class__.__name__} _read_until received %d bytes (hex prefix): %s", + len(full), + full[:200].hex(), + ) + return full + + except FtdiError as e: + retries += 1 + logger.warning( + f"{self.__class__.__name__} transient FtdiError while reading: %s — retrying", e + ) + + if retries >= max_retries: + logger.warning( + f"{self.__class__.__name__} too many FtdiError retries ({max_retries}) — stopping", e + ) + raise + + await asyncio.sleep(0.05) + continue + except Exception: + raise + + +# --------------------------------------------------------------------------- +# Device +# --------------------------------------------------------------------------- + + +class SynergyH1(Resource, Device): + """Agilent BioTek Synergy H1 plate reader.""" + + def __init__( + self, + name: str, + device_id: Optional[str] = None, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + backend = SynergyH1Backend(device_id=device_id) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Agilent BioTek Synergy H1", + ) + Device.__init__(self, backend=backend) + self._backend: SynergyH1Backend = backend + self.absorbance = AbsorbanceCapability(backend=backend) + self.luminescence = LuminescenceCapability(backend=backend) + self.fluorescence = FluorescenceCapability(backend=backend) + self._capabilities = [self.absorbance, self.luminescence, self.fluorescence] + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, + size_y=85.48, + size_z=0, # TODO: measure + pedestal_size_z=0, + child_location=Coordinate.zero(), # TODO: measure + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + async def open(self, slow: bool = False) -> None: + await self._backend.open(slow=slow) + + async def close(self, slow: bool = False) -> None: + await self._backend.close(slow=slow) diff --git a/pylabrobot/azenta/__init__.py b/pylabrobot/azenta/__init__.py new file mode 100644 index 00000000000..8a3a4c54a02 --- /dev/null +++ b/pylabrobot/azenta/__init__.py @@ -0,0 +1,2 @@ +from .a4s import A4S, A4SBackend +from .xpeel import XPeel, XPeelBackend diff --git a/pylabrobot/sealing/a4s_backend.py b/pylabrobot/azenta/a4s.py similarity index 73% rename from pylabrobot/sealing/a4s_backend.py rename to pylabrobot/azenta/a4s.py index 5c07cb4a333..b44de1cf75a 100644 --- a/pylabrobot/sealing/a4s_backend.py +++ b/pylabrobot/azenta/a4s.py @@ -2,7 +2,7 @@ import dataclasses import enum import time -from typing import Set +from typing import Optional, Set try: import serial @@ -12,42 +12,74 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e +from pylabrobot.capabilities.sealing import SealerBackend, SealingCapability +from pylabrobot.capabilities.temperature_controlling import ( + TemperatureControlCapability, + TemperatureControllerBackend, +) +from pylabrobot.device import Device, DeviceBackend from pylabrobot.io.serial import Serial -from pylabrobot.sealing.backend import SealerBackend +from pylabrobot.resources import Coordinate +from pylabrobot.resources.carrier import PlateHolder -class A4SBackend(SealerBackend): - def __init__(self, port: str, timeout=20) -> None: +class A4SBackend(SealerBackend, TemperatureControllerBackend): + """Backend for the Azenta a4S thermal sealer. + + https://web.azenta.com/hubfs/azenta-files/resources/tech-drawings/TD-automated-roll-heat-sealer.pdf + """ + + def __init__(self, port: str, timeout: int = 20) -> None: if not HAS_SERIAL: raise RuntimeError( "pyserial is not installed. Install with: pip install pylabrobot[serial]. " f"Import error: {_SERIAL_IMPORT_ERROR}" ) - super().__init__() self.port = port self.timeout = timeout self.io = Serial( + human_readable_device_name="Azenta a4S Thermal Sealer", port=self.port, baudrate=19200, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, - human_readable_device_name="A4S Sealer", ) async def setup(self): + await DeviceBackend.setup(self) await self.io.setup() await self.system_reset() async def stop(self): await self.set_heater(on=False) + await DeviceBackend.stop(self) await self.io.stop() - async def set_heater(self, on: bool): - """Set the heater on or off.""" - command = "*00H1ZZ" if on else "*00H0ZZ" - await self.send_command(command) - return await self._wait_for_status({A4SBackend.Status.SystemStatus.idle}) + # -- serial protocol -- + + async def send_command(self, command: str): + await self.io.write(command.encode()) + await asyncio.sleep(0.1) + + async def _read_message(self) -> str: + start = time.time() + r, x = b"", b"" + has_read_r = False + while x != b"" or (len(r) == 0 and x == b""): + x = await self.io.read() + if has_read_r: + r += x + if x == b"\r": + if not has_read_r: + has_read_r = True + else: + break + if time.time() - start > self.timeout: + raise TimeoutError("Timeout while waiting for response") + return r.decode("utf-8") + + # -- status -- @dataclasses.dataclass class Status: @@ -74,7 +106,6 @@ class SensorStatus: seal_roll_sensor: bool heater_motor_up_sensor: bool heater_motor_down_sensor: bool - # no_connect: bool current_temperature: float system_status: SystemStatus @@ -84,41 +115,16 @@ class SensorStatus: sensor_status: SensorStatus remaining_time: int - async def _read_message(self) -> str: - """read a message. we are not sure what format it is.""" - start = time.time() - r, x = b"", b"" - has_read_r = False - while x != b"" or (len(r) == 0 and x == b""): - x = await self.io.read() - if has_read_r: - r += x - if x == b"\r": - if not has_read_r: - has_read_r = True - else: - break - if time.time() - start > self.timeout: - raise TimeoutError("Timeout while waiting for response") - return r.decode("utf-8") - async def get_status(self) -> Status: - # read until we get a system status message - message: str while True: message = await self._read_message() - if message[1] == "T": # read system status + if message[1] == "T": break - # message[1] == b"D": # Operation Status Message Format - # message[1] == b"Y": # Response of Command Accepted Message Format - # message[1] == b"N": # Response of Command Rejected Message Format - # message[1] == b"X": # Communication Busy - # parsing response message = message.split("!")[0] parameters = message[:-4].split("=")[1].split(",") - error_code = int(str(parameters[3])) # 0 is good + error_code = int(str(parameters[3])) if error_code != 0: raise RuntimeError(f"An error occurred: response {message}") @@ -138,7 +144,6 @@ async def get_status(self) -> Status: seal_roll_sensor=sensor_status & 0x0010 != 0, heater_motor_up_sensor=sensor_status & 0x0020 != 0, heater_motor_down_sensor=sensor_status & 0x0040 != 0, - # no_connect = sensor_status & 0x0080 != 0, ), remaining_time=int(str(parameters[6])), ) @@ -159,31 +164,6 @@ async def _wait_for_status(self, statuses: Set["A4SBackend.Status.SystemStatus"] await asyncio.sleep(0.01) - async def send_command(self, command: str): - # command accepted: *Y01PL! - # Command index: 01 - await self.io.write(command.encode()) - await asyncio.sleep(0.1) - - async def seal(self, temperature: int, duration: float): - await self.set_temperature(temperature) - await self.set_time(duration) - await self.send_command("*00GS=zz!") # Command to conduct seal action - await self._wait_for_status({A4SBackend.Status.SystemStatus.single_cycle}) - return await self._wait_for_status( - {A4SBackend.Status.SystemStatus.idle, A4SBackend.Status.SystemStatus.finish} - ) - - async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: float = 0.5): - start = time.time() - while True: - current_temperature = await self.get_temperature() - if abs(current_temperature - degrees) < tolerance: - break - if time.time() - start > timeout: - raise TimeoutError("Timeout while waiting for temperature") - await asyncio.sleep(0.1) - async def _wait_for_shuttle_open_sensor( self, shuttle_open: bool, timeout: float = 30.0 ) -> Status: @@ -195,12 +175,14 @@ async def _wait_for_shuttle_open_sensor( if time.time() - start > timeout: raise TimeoutError("Timeout while waiting for shuttle open sensor") - async def set_temperature(self, temperature: float): - if not (50 <= temperature <= 200): - raise ValueError("Temperature out of range. Please enter a value between 50 and 200.") - command = f"*00DH={round(temperature):04d}zz!" + async def system_reset(self): + await self.send_command("*00SR=zz!") + return await self._wait_for_status({A4SBackend.Status.SystemStatus.idle}) + + async def set_heater(self, on: bool): + command = "*00H1ZZ" if on else "*00H0ZZ" await self.send_command(command) - await self._wait_for_temperature(temperature, timeout=300) + return await self._wait_for_status({A4SBackend.Status.SystemStatus.idle}) async def set_time(self, seconds: float): deciseconds = seconds * 10 @@ -209,6 +191,21 @@ async def set_time(self, seconds: float): command = f"*00DT={deciseconds:04d}zz!" return await self.send_command(command) + async def get_remaining_time(self) -> int: + status = await self.get_status() + return status.remaining_time + + # -- SealerBackend -- + + async def seal(self, temperature: int, duration: float): + await self.set_temperature(temperature) + await self.set_time(duration) + await self.send_command("*00GS=zz!") + await self._wait_for_status({A4SBackend.Status.SystemStatus.single_cycle}) + return await self._wait_for_status( + {A4SBackend.Status.SystemStatus.idle, A4SBackend.Status.SystemStatus.finish} + ) + async def open(self) -> Status: await self.send_command("*00MO=zz!") return await self._wait_for_shuttle_open_sensor(True) @@ -217,14 +214,75 @@ async def close(self) -> Status: await self.send_command("*00MC=zz!") return await self._wait_for_shuttle_open_sensor(False) - async def system_reset(self): - await self.send_command("*00SR=zz!") - return await self._wait_for_status({A4SBackend.Status.SystemStatus.idle}) + # -- TemperatureControllerBackend -- + + @property + def supports_active_cooling(self) -> bool: + return False - async def get_temperature(self) -> float: + async def set_temperature(self, temperature: float): + if not (50 <= temperature <= 200): + raise ValueError("Temperature out of range. Please enter a value between 50 and 200.") + command = f"*00DH={round(temperature):04d}zz!" + await self.send_command(command) + await self._wait_for_temperature(temperature, timeout=300) + + async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: float = 0.5): + start = time.time() + while True: + current_temperature = await self.get_current_temperature() + if abs(current_temperature - degrees) < tolerance: + break + if time.time() - start > timeout: + raise TimeoutError("Timeout while waiting for temperature") + await asyncio.sleep(0.1) + + async def get_current_temperature(self) -> float: status = await self.get_status() return status.current_temperature - async def get_remaining_time(self) -> int: - status = await self.get_status() - return status.remaining_time + async def deactivate(self): + await self.set_heater(on=False) + + +class A4S(PlateHolder, Device): + """Azenta a4S automated thermal sealer. + + 222 x 500 x 276 mm + """ + + def __init__( + self, + name: str, + backend: A4SBackend, + size_x: float = 222, + size_y: float = 500, + size_z: float = 276, + child_location: Coordinate = Coordinate(0, 0, 0), # TODO + pedestal_size_z: float = 0, # TODO + category: str = "sealer", + model: Optional[str] = None, + ): + raise NotImplementedError("A4S is missing resource definition.") + PlateHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + child_location=child_location, + pedestal_size_z=pedestal_size_z, + category=category, + model=model, + ) + Device.__init__(self, backend=backend) + self._backend: A4SBackend = backend + self.sealer = SealingCapability(backend=backend) + self.tc = TemperatureControlCapability(backend=backend) + self._capabilities = [self.tc, self.sealer] + + def serialize(self) -> dict: + return { + **Device.serialize(self), + **PlateHolder.serialize(self), + } diff --git a/pylabrobot/peeling/xpeel_backend.py b/pylabrobot/azenta/xpeel.py similarity index 55% rename from pylabrobot/peeling/xpeel_backend.py rename to pylabrobot/azenta/xpeel.py index 8ea1b4a6983..60697bba976 100644 --- a/pylabrobot/peeling/xpeel_backend.py +++ b/pylabrobot/azenta/xpeel.py @@ -1,7 +1,7 @@ import logging import time from dataclasses import dataclass -from typing import List, Literal, Tuple +from typing import List, Literal, Optional, Tuple try: import serial # type: ignore @@ -11,16 +11,13 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e +from pylabrobot.capabilities.peeling import PeelerBackend, PeelingCapability +from pylabrobot.device import Device from pylabrobot.io.serial import Serial -from pylabrobot.peeling.backend import PeelerBackend class XPeelBackend(PeelerBackend): - """ - Client for the Azenta Life Sciences Automated Plate Seal Remover (XPeel) - RS-232 interface. All commands use lowercase ASCII, begin with '*' and end - with . - """ + """Backend for the Azenta XPeel automated plate seal remover (RS-232).""" BAUDRATE = 9600 RESPONSE_TIMEOUT = 20.0 @@ -48,17 +45,16 @@ class ErrorInfo: 52: ErrorInfo(52, "Circuitry fault detected: remove power"), } - def __init__(self, port: str, logger=None, timeout=None): + def __init__(self, port: str, timeout: Optional[float] = None): if not HAS_SERIAL: raise RuntimeError( "pyserial is not installed. Install with: pip install pylabrobot[serial]. " f"Import error: {_SERIAL_IMPORT_ERROR}" ) - self.logger = logger or logging.getLogger(__name__) + self.logger = logging.getLogger(__name__) self.port = port self.response_timeout = timeout if timeout is not None else self.RESPONSE_TIMEOUT - self._serial_timeout = timeout if timeout is not None else self.response_timeout self.io = Serial( human_readable_device_name="XPeel", port=self.port, @@ -66,8 +62,8 @@ def __init__(self, port: str, logger=None, timeout=None): bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, - timeout=self._serial_timeout, - write_timeout=self._serial_timeout, + timeout=self.response_timeout, + write_timeout=self.response_timeout, rtscts=False, ) @@ -76,13 +72,9 @@ async def setup(self): async def stop(self): await self.io.stop() - self.logger.info("Serial interface closed.") @classmethod def describe_error(cls, code: int) -> str: - """ - Translate an XPeel error/status code to a human-readable message. - """ info = cls._ERROR_DEFINITIONS.get(code) if info: return info.description @@ -90,14 +82,9 @@ def describe_error(cls, code: int) -> str: @classmethod def parse_ready_line(cls, line: str): - """ - Parse a ready line like '*ready:06,01,00' to extract the primary error code - and its description. Returns a tuple (code: int, description: str). - """ if not line.startswith("*ready:"): return None try: - # Expected format: *ready:CC,PP,TT (CC = error/condition code) parts = line.split(":")[1].split(",") code = int(parts[0]) return code, cls.describe_error(code) @@ -107,17 +94,9 @@ def parse_ready_line(cls, line: str): async def _send_command( self, cmd, expect_ack=False, wait_for_ready=False, clear_buffer=True ) -> List[str]: - """ - Send a command and collect responses until *ready (optional) or timeout. - - Returns a list of response lines (strings). - """ full_cmd = cmd if cmd.endswith("\r\n") else f"{cmd}\r\n" - if self.io is None: - raise RuntimeError("Serial interface not initialized; call setup() first.") - - self.logger.debug(f"Sending command: {full_cmd.strip()}") + self.logger.debug("Sending command: %s", full_cmd.strip()) if clear_buffer: await self.io.reset_input_buffer() await self.io.write(full_cmd.encode("ascii")) @@ -138,8 +117,7 @@ async def _send_command( display_line = f"{line} [{desc}]" responses.append(display_line) - self.logger.info(f"Received: {display_line}") - print(f"Received: {display_line}") + self.logger.info("Received: %s", display_line) if line.startswith("*ack"): if not wait_for_ready: @@ -162,34 +140,20 @@ async def _send_command( return responses async def get_status(self) -> Tuple[int, int, int]: - """ - Request instrument status; returns *ready:XX,XX,XX. - - The 'XX' fields refer to the three possible error codes from the error table that - occurred during the previous Automated Plate Peeler motion. The error codes - will remain in the ready response until another motion command is made or until a - restart command is sent. A single commanded action may accumulate up to three errors. - Since they are logged as they occur (left to right), any error code may appear in - any error field. Successful completion of any motion command will clear all three - error codes back to 00. - """ - self.logger.debug("Requesting status...") + """Request instrument status; returns three error codes.""" resp = await self._send_command("*stat") return tuple([int(x) for x in resp[-1].split(":")[1].split(",")]) # type: ignore async def get_version(self): """Request firmware version.""" - self.logger.debug("Requesting firmware version...") return await self._send_command("*version") async def reset(self): - """Request reset; instrument replies with ack then ready.""" - self.logger.debug("Requesting reset...") + """Request reset.""" return await self._send_command("*reset", expect_ack=True, wait_for_ready=True) async def restart(self): - """Request restart; instrument replies with ack then poweron/homing/ready.""" - self.logger.debug("Requesting restart...") + """Request restart with full homing sequence.""" return await self._send_command("*restart", expect_ack=True, wait_for_ready=True) async def peel( @@ -198,19 +162,13 @@ async def peel( fast: bool = False, adhere_time: float = 2.5, ): - """ - Run an automated de-seal cycle. + """Run an automated de-seal cycle. Args: - begin_location: Begin peel location in mm relative to default (0). Must be one of: -2, 0, 2, 4. - fast: Use fast speed if True, slow if False. - adhere_time: Adhere time (seconds). Must be one of: 2.5, 5.0, 7.5, 10.0. + begin_location: Begin peel location in mm relative to default. Must be -2, 0, 2, or 4. + fast: Use fast speed if True. + adhere_time: Adhere time in seconds. Must be 2.5, 5.0, 7.5, or 10.0. """ - - self.logger.debug( - f"Running peel with begin_location={begin_location}, fast={fast}, adhere_time={adhere_time}..." - ) - if adhere_time not in {2.5, 5.0, 7.5, 10.0}: raise ValueError("adhere_time must be one of: 2.5, 5.0, 7.5, 10.0") if begin_location not in {-2, 0, 2, 4}: @@ -227,23 +185,11 @@ async def peel( (4, False): 8, }.get((begin_location, fast), 9) - if parameter_set not in range(1, 10): - raise ValueError("parameter_set must be in 1-9") cmd = f"*xpeel:{parameter_set}{adhere_time}" - return await self._send_command( - cmd, - expect_ack=True, - wait_for_ready=True, - ) + return await self._send_command(cmd, expect_ack=True, wait_for_ready=True) async def seal_check(self) -> Literal["seal_detected", "no_seal", "plate_not_detected"]: - """ - Check for seal presence; ready response encodes result. - - Response: - *ready:XX,00,00 (XX=04 if seal detected, XX=00 if no seal present, XX=06 if plate not detected) - """ - self.logger.debug("Checking for seal presence...") + """Check for seal presence.""" resp = await self._send_command("*sealcheck", expect_ack=True, wait_for_ready=True) ready_line = resp[-1] parsed = self.parse_ready_line(ready_line) @@ -261,16 +207,7 @@ async def seal_check(self) -> Literal["seal_detected", "no_seal", "plate_not_det ) async def get_tape_remaining(self): - """ - Query remaining tape. - - Response: - *tape:SS,TT - - Where 'SS' times 10 is the number of “deseals” remaining on the supply spool and 'TT' times 10 is the - number of “deseals” that can be held on the space remaining on the take-up spool. - """ - self.logger.debug("Querying remaining tape...") + """Query remaining tape. Returns (supply_remaining, takeup_remaining) in number of deseals.""" resp = await self._send_command("*tapeleft", expect_ack=True, wait_for_ready=True) tape_line = resp[-1] parts = tape_line.split(":")[1].split(",") @@ -280,88 +217,54 @@ async def get_tape_remaining(self): async def enable_plate_check(self, enabled=True): """Enable or disable plate presence check.""" - self.logger.debug(f"{'Enabling' if enabled else 'Disabling'} plate presence check...") flag = "y" if enabled else "n" - return await self._send_command( - f"*platecheck:{flag}", - expect_ack=True, - wait_for_ready=True, - ) + return await self._send_command(f"*platecheck:{flag}", expect_ack=True, wait_for_ready=True) async def get_seal_sensor_status(self): - """Get seal sensor threshold value (0-999)""" - self.logger.debug("Getting seal sensor threshold status...") + """Get seal sensor threshold value (0-999).""" return await self._send_command("*sealstat", expect_ack=True, wait_for_ready=True) async def set_seal_threshold_upper(self, value: int): - """ - Set the seal detected threshold value(0-999). - Sensor readings higher than this value will be considered as "seal not detected" - """ - self.logger.debug(f"Setting upper seal sensor threshold to {value}...") + """Set the upper seal detected threshold (0-999).""" if not 0 <= value <= 999: raise ValueError("value must be between 0 and 999") return await self._send_command( - f"*sealhigher:{value:03d}", - expect_ack=True, - wait_for_ready=True, + f"*sealhigher:{value:03d}", expect_ack=True, wait_for_ready=True ) async def set_seal_threshold_lower(self, value: int): - """ - Set the seal detected threshold value(0-999). - Sensor readings higher than this value will be considered as "seal not detected" - """ - self.logger.debug(f"Setting lower seal sensor threshold to {value}...") + """Set the lower seal detected threshold (0-999).""" if not 0 <= value <= 999: raise ValueError("value must be between 0 and 999") - return await self._send_command( - f"*seallower:{value:03d}", - expect_ack=True, - wait_for_ready=True, - ) + return await self._send_command(f"*seallower:{value:03d}", expect_ack=True, wait_for_ready=True) async def move_conveyor_out(self): - """Move conveyor out""" - self.logger.debug("Moving conveyor out...") - return await self._send_command( - "*moveout", - expect_ack=True, - wait_for_ready=True, - ) + """Move conveyor out.""" + return await self._send_command("*moveout", expect_ack=True, wait_for_ready=True) async def move_conveyor_in(self): - """Move conveyor in""" - self.logger.debug("Moving conveyor in...") - return await self._send_command( - "*movein", - expect_ack=True, - wait_for_ready=True, - ) + """Move conveyor in.""" + return await self._send_command("*movein", expect_ack=True, wait_for_ready=True) async def move_elevator_down(self): - """Move elevator down""" - self.logger.debug("Moving elevator down...") - return await self._send_command( - "*movedown", - expect_ack=True, - wait_for_ready=True, - ) + """Move elevator down.""" + return await self._send_command("*movedown", expect_ack=True, wait_for_ready=True) async def move_elevator_up(self): - """Move elevator up""" - self.logger.debug("Moving elevator up...") - return await self._send_command( - "*moveup", - expect_ack=True, - wait_for_ready=True, - ) + """Move elevator up.""" + return await self._send_command("*moveup", expect_ack=True, wait_for_ready=True) async def advance_tape(self): - """Advance tape / move spool""" - self.logger.debug("Advancing tape/spool...") - return await self._send_command( - "*movespool", - expect_ack=True, - wait_for_ready=True, - ) + """Advance tape / move spool.""" + return await self._send_command("*movespool", expect_ack=True, wait_for_ready=True) + + +class XPeel(Device): + """Azenta XPeel automated plate seal remover.""" + + def __init__(self, name: str, port: str, timeout: Optional[float] = None): + backend = XPeelBackend(port=port, timeout=timeout) + super().__init__(backend=backend) + self._backend: XPeelBackend = backend + self.peeler = PeelingCapability(backend=backend) + self._capabilities = [self.peeler] diff --git a/pylabrobot/barcode_scanners/__init__.py b/pylabrobot/barcode_scanners/__init__.py deleted file mode 100644 index befd981f9a9..00000000000 --- a/pylabrobot/barcode_scanners/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .backend import BarcodeScannerBackend, BarcodeScannerError -from .barcode_scanner import BarcodeScanner -from .keyence import KeyenceBarcodeScannerBackend diff --git a/pylabrobot/bmg_labtech/__init__.py b/pylabrobot/bmg_labtech/__init__.py new file mode 100644 index 00000000000..c24a8cd4761 --- /dev/null +++ b/pylabrobot/bmg_labtech/__init__.py @@ -0,0 +1 @@ +from .clariostar import CLARIOstar, CLARIOstarBackend diff --git a/pylabrobot/plate_reading/bmg_labtech/clario_star_backend.py b/pylabrobot/bmg_labtech/clariostar.py similarity index 53% rename from pylabrobot/plate_reading/bmg_labtech/clario_star_backend.py rename to pylabrobot/bmg_labtech/clariostar.py index f3459aa138e..8187f6a6a78 100644 --- a/pylabrobot/plate_reading/bmg_labtech/clario_star_backend.py +++ b/pylabrobot/bmg_labtech/clariostar.py @@ -4,14 +4,29 @@ import struct import sys import time -from typing import Dict, List, Optional, Tuple, Union - -from pylabrobot import utils +from typing import List, Optional, Union + +from pylabrobot.capabilities.plate_reading.absorbance import ( + AbsorbanceBackend, + AbsorbanceCapability, + AbsorbanceResult, +) +from pylabrobot.capabilities.plate_reading.fluorescence import ( + FluorescenceBackend, + FluorescenceCapability, + FluorescenceResult, +) +from pylabrobot.capabilities.plate_reading.luminescence import ( + LuminescenceBackend, + LuminescenceCapability, + LuminescenceResult, +) +from pylabrobot.device import Device from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources import Coordinate, PlateHolder, Resource from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well - -from ..backend import PlateReaderBackend +from pylabrobot.utils.list import reshape_2d if sys.version_info >= (3, 8): from typing import Literal @@ -21,100 +36,84 @@ logger = logging.getLogger("pylabrobot") -class CLARIOstarBackend(PlateReaderBackend): - """A plate reader backend for the Clario star. Note that this is not a complete implementation - and many commands and parameters are not implemented yet.""" +# --------------------------------------------------------------------------- +# Backend +# --------------------------------------------------------------------------- + + +class CLARIOstarBackend(AbsorbanceBackend, LuminescenceBackend, FluorescenceBackend): + """Backend for the BMG Labtech CLARIOstar plate reader. + + Communicates over FTDI USB (VID 0x0403, PID 0xBB68) at 125000 baud. + Supports absorbance and luminescence. Fluorescence is not yet implemented. + """ def __init__(self, device_id: Optional[str] = None): + super().__init__() self.io = FTDI( human_readable_device_name="BMG CLARIOstar", device_id=device_id, vid=0x0403, pid=0xBB68 ) - async def setup(self): + async def setup(self) -> None: await self.io.setup() await self.io.set_baudrate(125000) await self.io.set_line_property(8, 0, 0) # 8N1 await self.io.set_latency_timer(2) - await self.initialize() - await self.request_eeprom_data() + await self._initialize() + await self._request_eeprom_data() - async def stop(self): + async def stop(self) -> None: await self.io.stop() - async def get_stat(self): - stat = await self.io.poll_modem_status() - return hex(stat) - - async def read_resp(self, timeout=20) -> bytes: - """Read a response from the plate reader. If the timeout is reached, return the data that has - been read so far.""" + # -- Low-level protocol --------------------------------------------------- + async def read_resp(self, timeout: float = 20) -> bytes: + """Read a response terminated by 0x0D.""" d = b"" last_read = b"" end_byte_found = False t = time.time() - # Commands are terminated with 0x0d, but this value may also occur as a part of the response. - # Therefore, we read until we read a 0x0d, but if that's the last byte we read in a full packet, - # we keep reading for at least one more cycle. We only check the timeout if the last read was - # unsuccessful (i.e. keep reading if we are still getting data). while True: - last_read = await self.io.read(25) # 25 is max length observed in pcap + last_read = await self.io.read(25) if len(last_read) > 0: d += last_read end_byte_found = d[-1] == 0x0D - if ( - len(last_read) < 25 and end_byte_found - ): # if we read less than 25 bytes, we're at the end + if len(last_read) < 25 and end_byte_found: break else: - # If we didn't read any data, check if the last read ended in an end byte. If so, we're done if end_byte_found: break - - # Check if we've timed out. if time.time() - t > timeout: logger.warning("timed out reading response") break - - # If we read data, we don't wait and immediately try to read more. await asyncio.sleep(0.0001) logger.debug("read %s", d.hex()) - return d - async def send(self, cmd: Union[bytearray, bytes], read_timeout=20): - """Send a command to the plate reader and return the response.""" - + async def send(self, cmd: Union[bytearray, bytes], read_timeout: float = 20) -> bytes: + """Send a command with 16-bit checksum + 0x0D terminator and return the response.""" checksum = (sum(cmd) & 0xFFFF).to_bytes(2, byteorder="big") cmd = cmd + checksum + b"\x0d" - logger.debug("sending %s", cmd.hex()) - w = await self.io.write(cmd) - logger.debug("wrote %s bytes", w) - assert w == len(cmd) + return await self.read_resp(timeout=read_timeout) - resp = await self.read_resp(timeout=read_timeout) - return resp - - async def _wait_for_ready_and_return(self, ret, timeout=150): - """Wait for the plate reader to be ready and return the response.""" + async def _wait_for_ready_and_return(self, ret: bytes, timeout: float = 150) -> bytes: + """Poll command status until the device reports ready.""" last_status = None t = time.time() while time.time() - t < timeout: await asyncio.sleep(0.1) - - command_status = await self.read_command_status() + command_status = await self._read_command_status() if len(command_status) != 24: logger.warning( - "unexpected response %s. I think a command status response is always 24 bytes", - command_status, + "unexpected response %s. Expected 24 bytes for command status.", command_status ) continue @@ -125,53 +124,58 @@ async def _wait_for_ready_and_return(self, ret, timeout=150): continue if command_status[2] != 0x18 or command_status[3] != 0x0C or command_status[4] != 0x01: - logger.warning( - "unexpected response %s. I think 18 0c 01 indicates a command status response", - command_status, - ) + logger.warning("unexpected response header %s", command_status) - if command_status[5] not in { - 0x25, - 0x05, - }: # 25 is busy, 05 is ready. probably. - logger.warning("unexpected response %s.", command_status) + if command_status[5] not in {0x25, 0x05}: + logger.warning("unexpected status byte %s", command_status) if command_status[5] == 0x05: logger.debug("status is ready") return ret - async def read_command_status(self): - status = await self.send(b"\x02\x00\x09\x0c\x80\x00") - return status + raise TimeoutError("CLARIOstar did not become ready within timeout.") + + async def _read_command_status(self) -> bytes: + return await self.send(b"\x02\x00\x09\x0c\x80\x00") - async def initialize(self): + async def _initialize(self) -> None: command_response = await self.send(b"\x02\x00\x0d\x0c\x01\x00\x00\x10\x02\x00") - return await self._wait_for_ready_and_return(command_response) + await self._wait_for_ready_and_return(command_response) - async def request_eeprom_data(self): + async def _request_eeprom_data(self) -> None: eeprom_response = await self.send(b"\x02\x00\x0f\x0c\x05\x07\x00\x00\x00\x00\x00\x00") - return await self._wait_for_ready_and_return(eeprom_response) + await self._wait_for_ready_and_return(eeprom_response) - async def open(self): + # -- Tray control --------------------------------------------------------- + + async def open(self) -> None: + """Open the plate tray.""" open_response = await self.send(b"\x02\x00\x0e\x0c\x03\x01\x00\x00\x00\x00\x00") - return await self._wait_for_ready_and_return(open_response) + await self._wait_for_ready_and_return(open_response) - async def close(self, plate: Optional[Plate] = None): + async def close(self) -> None: + """Close the plate tray.""" close_response = await self.send(b"\x02\x00\x0e\x0c\x03\x00\x00\x00\x00\x00\x00") - return await self._wait_for_ready_and_return(close_response) + await self._wait_for_ready_and_return(close_response) - async def _mp_and_focus_height_value(self): - mp_and_focus_height_value_response = await self.send( - b"\x02\x00\x0f\x0c\x05\17\x00\x00\x00\x00" + b"\x00\x00" - ) - return await self._wait_for_ready_and_return(mp_and_focus_height_value_response) + # -- Helpers -------------------------------------------------------------- - def _plate_bytes(self, plate: Plate) -> bytes: - """Encode the plate geometry into the binary format the CLARIOstar expects. + async def _mp_and_focus_height_value(self) -> None: + resp = await self.send(b"\x02\x00\x0f\x0c\x05\17\x00\x00\x00\x00\x00\x00") + await self._wait_for_ready_and_return(resp) - Returns a 62-byte sequence: plate dimensions (12 bytes), column/row counts (2 bytes), - and a 384-bit well mask (48 bytes). - """ + async def _read_order_values(self) -> bytes: + return await self.send(b"\x02\x00\x0f\x0c\x05\x1d\x00\x00\x00\x00\x00\x00") + + async def _status_hw(self) -> bytes: + resp = await self.send(b"\x02\x00\x09\x0c\x81\x00") + return await self._wait_for_ready_and_return(resp) + + async def _get_measurement_values(self) -> bytes: + return await self.send(b"\x02\x00\x0f\x0c\x05\x02\x00\x00\x00\x00\x00\x00") + + def _plate_bytes(self, plate: Plate) -> bytes: + """Encode plate geometry into the 62-byte binary format.""" def float_to_bytes(f: float) -> bytes: return round(f * 100).to_bytes(2, byteorder="big") @@ -189,7 +193,6 @@ def float_to_bytes(f: float) -> bytes: plate_cols = plate.num_items_x plate_rows = plate.num_items_y - # 384-bit mask: first num_items bits set, rest zero wells = ([1] * plate.num_items) + ([0] * (384 - plate.num_items)) well_mask: int = sum(b << i for i, b in enumerate(wells[::-1])) wells_bytes = well_mask.to_bytes(48, "big") @@ -206,44 +209,38 @@ def float_to_bytes(f: float) -> bytes: + wells_bytes ) - async def _run_luminescence(self, focal_height: float, plate: Plate): - """Run a plate reader luminescence run.""" + # -- Measurement runs ----------------------------------------------------- + async def _run_luminescence(self, focal_height: float, plate: Plate) -> bytes: assert 0 <= focal_height <= 25, "focal height must be between 0 and 25 mm" - focal_height_data = int(focal_height * 100).to_bytes(2, byteorder="big") plate_bytes = self._plate_bytes(plate) payload = ( b"\x04" + plate_bytes + b"\x02\x01\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27" - b"\x0f\x27\x0f\x01" + focal_height_data + b"\x00\x00\x01\x00\x00\x0e\x10\x00\x01\x00\x01\x00" - b"\x01\x00\x01\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x01" - b"\x00\x00\x00\x01\x00\x64\x00\x20\x00\x00" + b"\x0f\x27\x0f\x01" + focal_height_data + b"\x00\x00\x01\x00\x00\x0e\x10\x00\x01\x00\x01" + b"\x00\x01\x00\x01\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00" + b"\x00\x01\x00\x00\x00\x01\x00\x64\x00\x20\x00\x00" ) message_size = (len(payload) + 7).to_bytes(2, byteorder="big") cmd = b"\x02" + message_size + b"\x0c" + payload run_response = await self.send(cmd) - # TODO: find a prettier way to do this. It's essentially copied from _wait_for_ready_and_return. last_status = None while True: await asyncio.sleep(0.1) - - command_status = await self.read_command_status() - + command_status = await self._read_command_status() if command_status != last_status: last_status = command_status logger.info("status changed %s", command_status) continue - if command_status == bytes( b"\x02\x00\x18\x0c\x01\x25\x04\x2e\x00\x00\x04\x01\x00\x00\x03\x00" b"\x00\x00\x00\xc0\x00\x01\x46\x0d" ): return run_response - async def _run_absorbance(self, wavelength: float, plate: Plate): - """Run a plate reader absorbance run.""" + async def _run_absorbance(self, wavelength: float, plate: Plate) -> bytes: wavelength_data = int(wavelength * 10).to_bytes(2, byteorder="big") plate_bytes = self._plate_bytes(plate) @@ -256,75 +253,49 @@ async def _run_absorbance(self, wavelength: float, plate: Plate): cmd = b"\x02" + message_size + b"\x0c" + payload run_response = await self.send(cmd) - # TODO: find a prettier way to do this. It's essentially copied from _wait_for_ready_and_return. last_status = None while True: await asyncio.sleep(0.1) - - command_status = await self.read_command_status() - + command_status = await self._read_command_status() if command_status != last_status: last_status = command_status logger.info("status changed %s", command_status) continue - if command_status == bytes( b"\x02\x00\x18\x0c\x01\x25\x04\x2e\x00\x00\x04\x01\x00\x00\x03\x00" b"\x00\x00\x00\xc0\x00\x01\x46\x0d" ): return run_response - async def _read_order_values(self): - return await self.send(b"\x02\x00\x0f\x0c\x05\x1d\x00\x00\x00\x00\x00\x00") - - async def _status_hw(self): - status_hw_response = await self.send(b"\x02\x00\x09\x0c\x81\x00") - return await self._wait_for_ready_and_return(status_hw_response) - - async def _get_measurement_values(self): - return await self.send(b"\x02\x00\x0f\x0c\x05\x02\x00\x00\x00\x00\x00\x00") + # -- Capability methods --------------------------------------------------- async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float = 13 - ) -> List[Dict]: - """Read luminescence values from the plate reader.""" + ) -> List[LuminescenceResult]: if wells != plate.get_all_items(): raise NotImplementedError("Only full plate reads are supported for now.") await self._mp_and_focus_height_value() - await self._run_luminescence(focal_height=focal_height, plate=plate) - await self._read_order_values() - await self._status_hw() vals = await self._get_measurement_values() - - # All values are 32 bit integers. The header is variable length, so we need to find the - # start of the data. In the future, when we understand the protocol better, this can be - # replaced with a more robust solution. num_wells = plate.num_items start_idx = vals.index(b"\x00\x00\x00\x00\x00\x00") + len(b"\x00\x00\x00\x00\x00\x00") data = list(vals)[start_idx : start_idx + num_wells * 4] - - # group bytes by 4 int_bytes = [data[i : i + 4] for i in range(0, len(data), 4)] - - # convert to int ints = [struct.unpack(">i", bytes(int_data))[0] for int_data in int_bytes] - - # for backend conformity, convert to float, and reshape to 2d array - floats: List[List[Optional[float]]] = utils.reshape_2d( + floats: List[List[Optional[float]]] = reshape_2d( [float(i) for i in ints], (plate.num_items_y, plate.num_items_x) ) return [ - { - "data": floats, - "temperature": float("nan"), # Temperature not available - "time": time.time(), - } + LuminescenceResult( + data=floats, + temperature=None, + timestamp=time.time(), + ) ] async def read_absorbance( @@ -333,28 +304,13 @@ async def read_absorbance( wells: List[Well], wavelength: int, report: Literal["OD", "transmittance"] = "OD", - ) -> List[Dict]: - """Read absorbance values from the device. - - Args: - wavelength: wavelength to read absorbance at, in nanometers. - report: whether to report absorbance as optical depth (OD) or transmittance. Transmittance is - used interchangeably with "transmission" in the CLARIOStar software and documentation. - - Returns: - A list containing a single dictionary, where the key is (wavelength, 0) and the value is - another dictionary containing the data, temperature, and time. - """ - + ) -> List[AbsorbanceResult]: if wells != plate.get_all_items(): raise NotImplementedError("Only full plate reads are supported for now.") await self._mp_and_focus_height_value() - await self._run_absorbance(wavelength=wavelength, plate=plate) - await self._read_order_values() - await self._status_hw() vals = await self._get_measurement_values() @@ -368,43 +324,34 @@ async def read_absorbance( chromatic_reading = [struct.unpack(">i", x)[0] for x in chromatic_bytes] reference_reading = [struct.unpack(">i", x)[0] for x in ref_bytes] - # c100 is the value of the chromatic at 100% intensity - # c0 is the value of the chromatic at 0% intensity (black reading) - # r100 is the value of the reference at 100% intensity - # r0 is the value of the reference at 0% intensity (black reading) after_values_idx = start_idx + (num_wells * 2) * 4 c100, c0, r100, r0 = struct.unpack(">iiii", vals[after_values_idx : after_values_idx + 4 * 4]) - # a bit much, but numpy should not be a dependency - real_chromatic_reading = [] - for cr in chromatic_reading: - real_chromatic_reading.append((cr - c0) / c100) - real_reference_reading = [] - for rr in reference_reading: - real_reference_reading.append((rr - r0) / r100) + real_chromatic_reading = [(cr - c0) / c100 for cr in chromatic_reading] + real_reference_reading = [(rr - r0) / r100 for rr in reference_reading] - transmittance: List[Optional[float]] = [] - for rcr, rrr in zip(real_chromatic_reading, real_reference_reading): - transmittance.append(rcr / rrr * 100) + transmittance: List[Optional[float]] = [ + rcr / rrr * 100 for rcr, rrr in zip(real_chromatic_reading, real_reference_reading) + ] data: List[List[Optional[float]]] if report == "OD": - od: List[Optional[float]] = [] - for t in transmittance: - od.append(math.log10(100 / t) if t is not None and t > 0 else None) - data = utils.reshape_2d(od, (plate.num_items_y, plate.num_items_x)) + od: List[Optional[float]] = [ + math.log10(100 / t) if t is not None and t > 0 else None for t in transmittance + ] + data = reshape_2d(od, (plate.num_items_y, plate.num_items_x)) elif report == "transmittance": - data = utils.reshape_2d(transmittance, (plate.num_items_y, plate.num_items_x)) + data = reshape_2d(transmittance, (plate.num_items_y, plate.num_items_x)) else: raise ValueError(f"Invalid report type: {report}") return [ - { - "wavelength": wavelength, - "data": data, - "temperature": float("nan"), # Temperature not available - "time": time.time(), - } + AbsorbanceResult( + data=data, + wavelength=wavelength, + temperature=None, + timestamp=time.time(), + ) ] async def read_fluorescence( @@ -414,21 +361,59 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, - ) -> List[Dict[Tuple[int, int], Dict]]: - raise NotImplementedError("Not implemented yet") - + ) -> List[FluorescenceResult]: + raise NotImplementedError("CLARIOstar fluorescence reading is not implemented yet.") -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 +# --------------------------------------------------------------------------- +# Device +# --------------------------------------------------------------------------- -class CLARIOStar: - def __init__(self, *args, **kwargs): - raise RuntimeError("`CLARIOStar` is deprecated. Please use `CLARIOStarBackend` instead.") +class CLARIOstar(Resource, Device): + """BMG Labtech CLARIOstar plate reader.""" -class CLARIOStarBackend: - def __init__(self, *args, **kwargs): - raise RuntimeError( - "`CLARIOStarBackend` (capital 'S') is deprecated. Please use `CLARIOstarBackend` instead." + def __init__( + self, + name: str, + device_id: Optional[str] = None, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + backend = CLARIOstarBackend(device_id=device_id) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="BMG CLARIOstar", + ) + Device.__init__(self, backend=backend) + self._backend: CLARIOstarBackend = backend + self.absorbance = AbsorbanceCapability(backend=backend) + self.luminescence = LuminescenceCapability(backend=backend) + self.fluorescence = FluorescenceCapability(backend=backend) + self._capabilities = [self.absorbance, self.luminescence, self.fluorescence] + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, # TODO: measure + size_y=85.48, # TODO: measure + size_z=0, + pedestal_size_z=0, + child_location=Coordinate.zero(), # TODO: measure ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + async def open(self) -> None: + """Open the plate tray.""" + await self._backend.open() + + async def close(self) -> None: + """Close the plate tray.""" + await self._backend.close() diff --git a/pylabrobot/byonoy/__init__.py b/pylabrobot/byonoy/__init__.py new file mode 100644 index 00000000000..c9289dff529 --- /dev/null +++ b/pylabrobot/byonoy/__init__.py @@ -0,0 +1,21 @@ +from .absorbance_96 import ( + ByonoyAbsorbance96, + ByonoyAbsorbance96Backend, + ByonoyAbsorbanceBaseUnit, + byonoy_a96a, + byonoy_a96a_detection_unit, + byonoy_a96a_illumination_unit, + byonoy_a96a_parking_unit, + byonoy_sbs_adapter, +) +from .luminescence_96 import ( + ByonoyLuminescence96, + ByonoyLuminescence96Backend, + ByonoyLuminescenceBaseUnit, + byonoy_l96, + byonoy_l96_base_unit, + byonoy_l96_reader_unit, + byonoy_l96a, + byonoy_l96a_base_unit, + byonoy_l96a_reader_unit, +) diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py new file mode 100644 index 00000000000..658bae6d325 --- /dev/null +++ b/pylabrobot/byonoy/absorbance_96.py @@ -0,0 +1,304 @@ +import time +from typing import List, Optional, Tuple + +from pylabrobot.byonoy.backend import ByonoyBase, ByonoyDevice +from pylabrobot.capabilities.plate_reading.absorbance import ( + AbsorbanceBackend, + AbsorbanceCapability, + AbsorbanceResult, +) +from pylabrobot.device import Device +from pylabrobot.io.binary import Reader, Writer +from pylabrobot.resources import Coordinate, PlateHolder, Resource, ResourceHolder +from pylabrobot.resources.barcode import Barcode +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.rotation import Rotation +from pylabrobot.resources.well import Well +from pylabrobot.utils.list import reshape_2d + +# --------------------------------------------------------------------------- +# Backend +# --------------------------------------------------------------------------- + + +class ByonoyAbsorbance96Backend(ByonoyBase, AbsorbanceBackend): + """Backend for the Byonoy Absorbance 96 Automate plate reader.""" + + def __init__(self) -> None: + super().__init__(pid=0x1199, device_type=ByonoyDevice.ABSORBANCE_96) + self.available_wavelengths: List[float] = [] + + async def setup(self, **backend_kwargs) -> None: + await super().setup(**backend_kwargs) + await self.initialize_measurements() + self.available_wavelengths = await self.get_available_absorbance_wavelengths() + + async def get_available_absorbance_wavelengths(self) -> List[float]: + response = await self.send_command( + report_id=0x0330, + payload=b"\x00" * 60, + wait_for_response=True, + routing_info=b"\x80\x40", + ) + assert response is not None, "Failed to get available wavelengths." + reader = Reader(response[2:]) + available_wavelengths = [reader.i16() for _ in range(30)] + return [w for w in available_wavelengths if w != 0] + + async def _run_abs_measurement(self, signal_wl: int, reference_wl: int, is_reference: bool): + await self.send_command( + report_id=0x0010, + payload=b"\x00" * 60, + wait_for_response=False, + ) + + payload2 = Writer().u16(7).u8(0).raw_bytes(b"\x00" * 52).finish() + await self.send_command( + report_id=0x0200, + payload=payload2, + wait_for_response=False, + ) + + payload3 = Writer().i16(signal_wl).i16(reference_wl).u8(int(is_reference)).u8(0).finish() + await self.send_command( + report_id=0x0320, + payload=payload3, + wait_for_response=False, + routing_info=b"\x00\x40", + ) + + rows: List[float] = [] + t0 = time.time() + + while True: + if time.time() - t0 > 120: + raise TimeoutError("Measurement timeout.") + + chunk = await self.io.read(64, timeout=30) + if len(chunk) == 0: + continue + + reader = Reader(chunk) + report_id = reader.u16() + + if report_id == 0x0500: + seq = reader.u8() + seq_len = reader.u8() + _ = reader.i16() # signal_wl_nm + _ = reader.i16() # reference_wl_nm + _ = reader.u32() # duration_ms + row = [reader.f32() for _ in range(12)] + _ = reader.u8() # flags + _ = reader.u8() # progress + + rows.extend(row) + + if seq == seq_len - 1: + break + + return rows + + async def initialize_measurements(self): + REFERENCE_WL = 0 + SIGNAL_WL = 660 + await self._run_abs_measurement( + signal_wl=SIGNAL_WL, + reference_wl=REFERENCE_WL, + is_reference=True, + ) + + async def read_absorbance( + self, plate: Plate, wells: List[Well], wavelength: int + ) -> List[AbsorbanceResult]: + assert wavelength in self.available_wavelengths, ( + f"Wavelength {wavelength} nm not in available wavelengths {self.available_wavelengths}." + ) + + rows = await self._run_abs_measurement( + signal_wl=wavelength, + reference_wl=0, + is_reference=False, + ) + + matrix = reshape_2d(rows, (8, 12)) + + return [ + AbsorbanceResult( + data=matrix, + wavelength=wavelength, + temperature=None, + timestamp=time.time(), + ) + ] + + +# --------------------------------------------------------------------------- +# Resources +# --------------------------------------------------------------------------- + + +class _ByonoyAbsorbanceReaderPlateHolder(PlateHolder): + """Plate holder with interlock: blocks drops while illumination unit is on the base.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + pedestal_size_z: float = 0, + child_location: Coordinate = Coordinate.zero(), + category: str = "plate_holder", + model: Optional[str] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + pedestal_size_z=pedestal_size_z, + child_location=child_location, + category=category, + model=model, + ) + self._byonoy_base: Optional[ByonoyAbsorbanceBaseUnit] = None + + def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: + if self._byonoy_base is None: + raise RuntimeError( + "Plate holder not assigned to a ByonoyAbsorbanceBaseUnit. This should not happen." + ) + if self._byonoy_base.illumination_unit_holder.resource is not None: + raise RuntimeError( + f"Cannot drop resource {resource.name} onto plate holder while illumination unit is on " + "the base. Please remove the illumination unit from the base before dropping a resource." + ) + super().check_can_drop_resource_here(resource, reassign=reassign) + + +class ByonoyAbsorbanceBaseUnit(Resource): + def __init__( + self, + name: str, + size_x: float = 155.26, + size_y: float = 95.48, + size_z: float = 18.5, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = None, + barcode: Optional[Barcode] = None, + preferred_pickup_location: Optional[Coordinate] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + barcode=barcode, + preferred_pickup_location=preferred_pickup_location, + ) + + self.plate_holder = _ByonoyAbsorbanceReaderPlateHolder( + name=self.name + "_plate_holder", + size_x=127.76, + size_y=85.59, + size_z=0, + child_location=Coordinate(x=22.5, y=5.0, z=16.0), + pedestal_size_z=0, + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + self.illumination_unit_holder = ResourceHolder( + name=self.name + "_illumination_unit_holder", + size_x=size_x, + size_y=size_y, + size_z=0, + child_location=Coordinate(x=0, y=0, z=14.1), + ) + self.assign_child_resource(self.illumination_unit_holder, location=Coordinate.zero()) + + def assign_child_resource( + self, resource: Resource, location: Optional[Coordinate], reassign: bool = True + ) -> None: + if isinstance(resource, _ByonoyAbsorbanceReaderPlateHolder): + if self.plate_holder._byonoy_base is not None: + raise ValueError("ByonoyBase can only have one plate holder assigned.") + self.plate_holder._byonoy_base = self + super().assign_child_resource(resource, location, reassign) + + def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: + raise RuntimeError( + "ByonoyBase does not support assigning child resources directly. " + "Use the plate_holder or illumination_unit_holder to assign plates and the " + "illumination unit, respectively." + ) + + +def byonoy_sbs_adapter(name: str) -> ResourceHolder: + """Create a Byonoy SBS adapter ResourceHolder.""" + return ResourceHolder( + name=name, + size_x=127.76, + size_y=85.48, + size_z=17.0, + child_location=Coordinate( + x=-(155.26 - 127.76) / 2, + y=-(95.48 - 85.48) / 2, + z=17.0, + ), + ) + + +def byonoy_a96a_illumination_unit(name: str) -> Resource: + size_x = 155.26 + size_y = 95.48 + return Resource( + name=name, + size_x=size_x, + size_y=size_y, + size_z=42.898, + model="Byonoy A96A Illumination Unit", + preferred_pickup_location=Coordinate(x=size_x / 2, y=size_y / 2, z=29.5), + ) + + +# --------------------------------------------------------------------------- +# Device + Resource composite +# --------------------------------------------------------------------------- + + +class ByonoyAbsorbance96(ByonoyAbsorbanceBaseUnit, Device): + """Byonoy Absorbance 96 Automate plate reader.""" + + def __init__(self, name: str = "byonoy_absorbance_96"): + backend = ByonoyAbsorbance96Backend() + ByonoyAbsorbanceBaseUnit.__init__(self, name=name + "_base") + Device.__init__(self, backend=backend) + self._backend: ByonoyAbsorbance96Backend = backend + self.absorbance = AbsorbanceCapability(backend=backend) + self._capabilities = [self.absorbance] + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + +def byonoy_a96a_detection_unit(name: str) -> ByonoyAbsorbance96: + """Create a Byonoy A96A detection unit.""" + return ByonoyAbsorbance96(name=name) + + +def byonoy_a96a_parking_unit(name: str) -> ByonoyAbsorbanceBaseUnit: + """Create a Byonoy A96A detection unit holder (base only, no backend).""" + return ByonoyAbsorbanceBaseUnit(name=name) + + +def byonoy_a96a(name: str, assign: bool = True) -> Tuple[ByonoyAbsorbance96, Resource]: + """Create a full Byonoy A96A setup (reader + illumination unit).""" + reader = byonoy_a96a_detection_unit(name=name + "_reader") + illumination_unit = byonoy_a96a_illumination_unit(name=name + "_illumination_unit") + if assign: + reader.illumination_unit_holder.assign_child_resource(illumination_unit) + return reader, illumination_unit diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py new file mode 100644 index 00000000000..95955cfd833 --- /dev/null +++ b/pylabrobot/byonoy/backend.py @@ -0,0 +1,95 @@ +import asyncio +import enum +import threading +import time +from abc import ABCMeta +from typing import Optional + +from pylabrobot.device import DeviceBackend +from pylabrobot.io.binary import Reader, Writer +from pylabrobot.io.hid import HID + + +class ByonoyDevice(enum.Enum): + ABSORBANCE_96 = enum.auto() + LUMINESCENCE_96 = enum.auto() + + +class ByonoyBase(DeviceBackend, metaclass=ABCMeta): + """Shared HID communication logic for Byonoy plate readers.""" + + def __init__(self, pid: int, device_type: ByonoyDevice) -> None: + super().__init__() + self.io = HID(human_readable_device_name="Byonoy Plate Reader", vid=0x16D0, pid=pid) + self._background_thread: Optional[threading.Thread] = None + self._stop_background = threading.Event() + self._ping_interval = 1.0 + self._sending_pings = False + self._device_type = device_type + + async def setup(self) -> None: + await self.io.setup() + self._stop_background.clear() + self._background_thread = threading.Thread(target=self._background_ping_worker, daemon=True) + self._background_thread.start() + + async def stop(self) -> None: + self._stop_background.set() + if self._background_thread and self._background_thread.is_alive(): + self._background_thread.join(timeout=2.0) + await self.io.stop() + + def _assemble_command(self, report_id: int, payload: bytes, routing_info: bytes) -> bytes: + packet = Writer().u16(report_id).raw_bytes(payload).finish() + packet += b"\x00" * (62 - len(packet)) + routing_info + return packet + + async def send_command( + self, + report_id: int, + payload: bytes, + wait_for_response: bool = True, + routing_info: bytes = b"\x00\x00", + ) -> Optional[bytes]: + command = self._assemble_command(report_id, payload=payload, routing_info=routing_info) + await self.io.write(command) + if not wait_for_response: + return None + + t0 = time.time() + while True: + if time.time() - t0 > 120: + raise TimeoutError("Reading data timed out after 2 minutes.") + response = await self.io.read(64, timeout=30) + if len(response) == 0: + continue + response_report_id = Reader(response).u16() + if report_id == response_report_id: + break + return response + + def _background_ping_worker(self) -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self._ping_loop()) + finally: + loop.close() + + async def _ping_loop(self) -> None: + while not self._stop_background.is_set(): + if self._sending_pings: + payload = Writer().u8(1).finish() + cmd = self._assemble_command( + report_id=0x0040, + payload=payload, + routing_info=b"\x00\x00", + ) + await self.io.write(cmd) + self._stop_background.wait(self._ping_interval) + + def _start_background_pings(self) -> None: + self._sending_pings = True + + def _stop_background_pings(self) -> None: + self._sending_pings = False diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py new file mode 100644 index 00000000000..462cf8b9248 --- /dev/null +++ b/pylabrobot/byonoy/luminescence_96.py @@ -0,0 +1,307 @@ +import time +from typing import List, Optional, Tuple + +from pylabrobot.byonoy.backend import ByonoyBase, ByonoyDevice +from pylabrobot.capabilities.plate_reading.luminescence import ( + LuminescenceBackend, + LuminescenceCapability, + LuminescenceResult, +) +from pylabrobot.device import Device +from pylabrobot.io.binary import Reader, Writer +from pylabrobot.resources import Coordinate, PlateHolder, Resource, ResourceHolder +from pylabrobot.resources.barcode import Barcode +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.rotation import Rotation +from pylabrobot.resources.well import Well +from pylabrobot.utils.list import reshape_2d + +# --------------------------------------------------------------------------- +# Backend +# --------------------------------------------------------------------------- + + +class ByonoyLuminescence96Backend(ByonoyBase, LuminescenceBackend): + """Backend for the Byonoy Luminescence 96 Automate plate reader.""" + + def __init__(self) -> None: + super().__init__(pid=0x119B, device_type=ByonoyDevice.LUMINESCENCE_96) + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 2 + ) -> List[LuminescenceResult]: + """Read luminescence. + + Args: + plate: The plate being read. + wells: Wells to measure. + focal_height: Focal height in mm. + integration_time: Integration time in seconds, default 2 s. + """ + + await self.send_command( + report_id=0x0010, + payload=b"\x00" * 60, + wait_for_response=False, + ) + + payload2 = Writer().u16(7).u8(0).raw_bytes(b"\x00" * 52).finish() + await self.send_command( + report_id=0x0200, + payload=payload2, + wait_for_response=False, + ) + + payload3 = ( + Writer().i32(int(integration_time * 1000 * 1000)).raw_bytes(b"\xff" * 12).u8(0).u8(0).finish() + ) + await self.send_command( + report_id=0x0340, + payload=payload3, + wait_for_response=False, + ) + + t0 = time.time() + all_rows: List[Optional[float]] = [] + + while True: + if time.time() - t0 > 120: + raise TimeoutError("Reading luminescence data timed out after 2 minutes.") + + chunk = await self.io.read(64, timeout=30) + if len(chunk) == 0: + continue + + reader = Reader(chunk) + report_id = reader.u16() + + if report_id == 0x0600: + seq = reader.u8() + seq_len = reader.u8() + _ = reader.u32() # integration_time_us + _ = reader.u32() # duration_ms + row = [reader.f32() for _ in range(12)] + _ = reader.u8() # flags + _ = reader.u8() # progress + + all_rows.extend(row) + + if seq == seq_len - 1: + break + + hybrid_result: List[Optional[float]] = all_rows[96 * 0 : 96 * 1] + + return [ + LuminescenceResult( + data=reshape_2d(hybrid_result, (8, 12)), + temperature=None, + timestamp=time.time(), + ) + ] + + +# --------------------------------------------------------------------------- +# Resources +# --------------------------------------------------------------------------- + + +class _ByonoyLuminescenceReaderPlateHolder(PlateHolder): + """Plate holder with interlock: blocks drops while reader unit is on the base.""" + + def __init__( + self, + name: str, + child_location: Coordinate = Coordinate.zero(), + category: str = "plate_holder", + model: Optional[str] = None, + ): + super().__init__( + name=name, + size_x=127.76, + size_y=85.59, + size_z=0, + pedestal_size_z=0, + child_location=child_location, + category=category, + model=model, + ) + self._byonoy_base: Optional[ByonoyLuminescenceBaseUnit] = None + + def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: + if self._byonoy_base is None: + raise RuntimeError( + "Plate holder not assigned to a ByonoyLuminescenceBaseUnit. This should not happen." + ) + if self._byonoy_base.reader_unit_holder.resource is not None: + raise RuntimeError( + f"Cannot drop resource {resource.name} onto plate holder while reader unit is on " + "the base. Please remove the reader unit from the base before dropping a resource." + ) + super().check_can_drop_resource_here(resource, reassign=reassign) + + +class ByonoyLuminescenceBaseUnit(Resource): + """Base unit for the Byonoy L96/L96A luminescence reader.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + plate_holder_child_location: Coordinate, + reader_unit_holder_child_location: Coordinate, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = None, + barcode: Optional[Barcode] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + barcode=barcode, + ) + + self.plate_holder = _ByonoyLuminescenceReaderPlateHolder( + name=self.name + "_plate_holder", + child_location=plate_holder_child_location, + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + self.reader_unit_holder = ResourceHolder( + name=self.name + "_reader_unit_holder", + size_x=size_x, + size_y=size_y, + size_z=0, + child_location=reader_unit_holder_child_location, + ) + self.assign_child_resource(self.reader_unit_holder, location=Coordinate.zero()) + + def assign_child_resource( + self, resource: Resource, location: Optional[Coordinate], reassign: bool = True + ) -> None: + if isinstance(resource, _ByonoyLuminescenceReaderPlateHolder): + if self.plate_holder._byonoy_base is not None: + raise ValueError("ByonoyBase can only have one plate holder assigned.") + self.plate_holder._byonoy_base = self + super().assign_child_resource(resource, location, reassign) + + def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: + raise RuntimeError( + "ByonoyBase does not support assigning child resources directly. " + "Use the plate_holder or reader_unit_holder to assign plates and the reader unit, " + "respectively." + ) + + +# --------------------------------------------------------------------------- +# Device (reader unit — sits on top of a ByonoyLuminescenceBaseUnit) +# --------------------------------------------------------------------------- + + +class ByonoyLuminescence96(Resource, Device): + """Byonoy Luminescence 96 reader unit.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + preferred_pickup_location: Optional[Coordinate] = None, + ): + backend = ByonoyLuminescence96Backend() + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Byonoy L96 Reader Unit", + preferred_pickup_location=preferred_pickup_location, + ) + Device.__init__(self, backend=backend) + self._backend: ByonoyLuminescence96Backend = backend + self.luminescence = LuminescenceCapability(backend=backend) + self._capabilities = [self.luminescence] + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + +# --------------------------------------------------------------------------- +# Factory functions +# --------------------------------------------------------------------------- + + +def byonoy_l96_reader_unit(name: str) -> ByonoyLuminescence96: + """Create a Byonoy L96 reader unit (non-automate, no preferred pickup).""" + return ByonoyLuminescence96( + name=name, + size_x=139.7, + size_y=97.5, + size_z=35, + preferred_pickup_location=None, + ) + + +def byonoy_l96_base_unit(name: str) -> ByonoyLuminescenceBaseUnit: + """Create a Byonoy L96 base unit.""" + return ByonoyLuminescenceBaseUnit( + name=name, + size_x=139.7, + size_y=97.5, + size_z=9.4, + plate_holder_child_location=Coordinate(x=6.25, y=6.1, z=2.64), + reader_unit_holder_child_location=Coordinate(x=0, y=0, z=7.2), + ) + + +def byonoy_l96( + name: str, assign: bool = True +) -> Tuple[ByonoyLuminescenceBaseUnit, ByonoyLuminescence96]: + """Create a full Byonoy L96 setup (base + reader).""" + base_unit = byonoy_l96_base_unit(name=name + "_base") + reader_unit = byonoy_l96_reader_unit(name=name + "_reader") + if assign: + base_unit.reader_unit_holder.assign_child_resource(reader_unit) + return base_unit, reader_unit + + +def byonoy_l96a_reader_unit(name: str) -> ByonoyLuminescence96: + """Create a Byonoy L96A reader unit (automate, with preferred pickup).""" + return ByonoyLuminescence96( + name=name, + size_x=138, + size_y=97.5, + size_z=41.7, + preferred_pickup_location=Coordinate(x=69, y=48.75, z=33.2), + ) + + +def byonoy_l96a_base_unit(name: str) -> ByonoyLuminescenceBaseUnit: + """Create a Byonoy L96A base unit.""" + return ByonoyLuminescenceBaseUnit( + name=name, + size_x=138, + size_y=97.5, + size_z=10.7, + plate_holder_child_location=Coordinate(x=5.1, y=4.75, z=8), + reader_unit_holder_child_location=Coordinate(x=0, y=0, z=6.3), + ) + + +def byonoy_l96a( + name: str, assign: bool = True +) -> Tuple[ByonoyLuminescenceBaseUnit, ByonoyLuminescence96]: + """Create a full Byonoy L96A setup (base + reader).""" + base_unit = byonoy_l96a_base_unit(name=name + "_base") + reader_unit = byonoy_l96a_reader_unit(name=name + "_reader") + if assign: + base_unit.reader_unit_holder.assign_child_resource(reader_unit) + return base_unit, reader_unit diff --git a/pylabrobot/capabilities/__init__.py b/pylabrobot/capabilities/__init__.py new file mode 100644 index 00000000000..cbd53bcd61a --- /dev/null +++ b/pylabrobot/capabilities/__init__.py @@ -0,0 +1 @@ +from .capability import Capability, need_capability_ready diff --git a/pylabrobot/capabilities/automated_retrieval/__init__.py b/pylabrobot/capabilities/automated_retrieval/__init__.py new file mode 100644 index 00000000000..3cec37375ef --- /dev/null +++ b/pylabrobot/capabilities/automated_retrieval/__init__.py @@ -0,0 +1,2 @@ +from .automated_retrieval import AutomatedRetrievalCapability +from .backend import AutomatedRetrievalBackend diff --git a/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py new file mode 100644 index 00000000000..eb609f48b05 --- /dev/null +++ b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py @@ -0,0 +1,23 @@ +from pylabrobot.capabilities.capability import Capability +from pylabrobot.resources import Plate, PlateHolder + +from .backend import AutomatedRetrievalBackend + + +class AutomatedRetrievalCapability(Capability): + """Automated plate retrieval/storage capability.""" + + def __init__(self, backend: AutomatedRetrievalBackend): + super().__init__(backend=backend) + self.backend: AutomatedRetrievalBackend = backend + + async def fetch_plate_to_loading_tray(self, plate: Plate): + """Retrieve a plate from storage and place it on the loading tray.""" + await self.backend.fetch_plate_to_loading_tray(plate) + + async def store_plate(self, plate: Plate, site: PlateHolder): + """Store a plate from the loading tray into the given site.""" + await self.backend.store_plate(plate, site) + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/capabilities/automated_retrieval/backend.py b/pylabrobot/capabilities/automated_retrieval/backend.py new file mode 100644 index 00000000000..11048e98e69 --- /dev/null +++ b/pylabrobot/capabilities/automated_retrieval/backend.py @@ -0,0 +1,16 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.device import DeviceBackend +from pylabrobot.resources import Plate, PlateHolder + + +class AutomatedRetrievalBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for automated plate retrieval/storage devices.""" + + @abstractmethod + async def fetch_plate_to_loading_tray(self, plate: Plate): + """Retrieve a plate from storage and place it on the loading tray.""" + + @abstractmethod + async def store_plate(self, plate: Plate, site: PlateHolder): + """Store a plate from the loading tray into the given site.""" diff --git a/pylabrobot/capabilities/barcode_scanning/__init__.py b/pylabrobot/capabilities/barcode_scanning/__init__.py new file mode 100644 index 00000000000..535b3a6425e --- /dev/null +++ b/pylabrobot/capabilities/barcode_scanning/__init__.py @@ -0,0 +1,2 @@ +from .backend import BarcodeScannerBackend, BarcodeScannerError +from .barcode_scanning import BarcodeScanningCapability diff --git a/pylabrobot/capabilities/barcode_scanning/backend.py b/pylabrobot/capabilities/barcode_scanning/backend.py new file mode 100644 index 00000000000..49e89ee7d8c --- /dev/null +++ b/pylabrobot/capabilities/barcode_scanning/backend.py @@ -0,0 +1,16 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.device import DeviceBackend +from pylabrobot.resources.barcode import Barcode + + +class BarcodeScannerError(Exception): + """Error raised by a barcode scanner backend.""" + + +class BarcodeScannerBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for barcode scanning devices.""" + + @abstractmethod + async def scan_barcode(self) -> Barcode: + """Scan a barcode and return its value.""" diff --git a/pylabrobot/barcode_scanners/barcode_scanner.py b/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py similarity index 57% rename from pylabrobot/barcode_scanners/barcode_scanner.py rename to pylabrobot/capabilities/barcode_scanning/barcode_scanning.py index 821e5789ae2..0be931e9512 100644 --- a/pylabrobot/barcode_scanners/barcode_scanner.py +++ b/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py @@ -1,10 +1,11 @@ -from pylabrobot.barcode_scanners.backend import BarcodeScannerBackend -from pylabrobot.machines.machine import Machine +from pylabrobot.capabilities.capability import Capability from pylabrobot.resources.barcode import Barcode +from .backend import BarcodeScannerBackend -class BarcodeScanner(Machine): - """Frontend for barcode scanners.""" + +class BarcodeScanningCapability(Capability): + """Barcode scanning capability.""" def __init__(self, backend: BarcodeScannerBackend): super().__init__(backend=backend) @@ -13,3 +14,6 @@ def __init__(self, backend: BarcodeScannerBackend): async def scan(self) -> Barcode: """Scan a barcode and return its value.""" return await self.backend.scan_barcode() + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/capabilities/capability.py b/pylabrobot/capabilities/capability.py new file mode 100644 index 00000000000..fd51ed3bbba --- /dev/null +++ b/pylabrobot/capabilities/capability.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import functools +import sys +from abc import ABC +from typing import Any, Awaitable, Callable, TypeVar + +from pylabrobot.device import DeviceBackend + +if sys.version_info < (3, 10): + from typing_extensions import ParamSpec +else: + from typing import ParamSpec + +_P = ParamSpec("_P") +_R = TypeVar("_R", bound=Awaitable[Any]) + + +def need_capability_ready(func: Callable[_P, _R]) -> Callable[_P, _R]: + """Decorator for methods that require the capability to be set up. + + Checked by verifying `self.setup_finished` is `True`. + + Raises: + RuntimeError: If the capability is not set up. + """ + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + assert isinstance(args[0], Capability), "The first argument must be a Capability." + self = args[0] + + if not self.setup_finished: + raise RuntimeError("The capability has not been set up. Call setup() on the parent device.") + return await func(*args, **kwargs) + + return wrapper + + +class Capability(ABC): + """Base class for device capabilities. + + Capabilities are owned by a Device and share its backend. They are not Resources + and do not appear in the resource tree. The parent Device is responsible for calling + `_on_setup()` and `_on_stop()` during its own setup/stop lifecycle. + """ + + def __init__(self, backend: DeviceBackend): + self.backend = backend + self._setup_finished = False + + @property + def setup_finished(self) -> bool: + return self._setup_finished + + async def _on_setup(self): + """Called by the parent Device after backend.setup() completes.""" + self._setup_finished = True + + async def _on_stop(self): + """Called by the parent Device before backend.stop().""" + self._setup_finished = False diff --git a/pylabrobot/capabilities/fan_control/__init__.py b/pylabrobot/capabilities/fan_control/__init__.py new file mode 100644 index 00000000000..0d9aeefb401 --- /dev/null +++ b/pylabrobot/capabilities/fan_control/__init__.py @@ -0,0 +1,2 @@ +from .backend import FanBackend +from .fan_control import FanControlCapability diff --git a/pylabrobot/capabilities/fan_control/backend.py b/pylabrobot/capabilities/fan_control/backend.py new file mode 100644 index 00000000000..815336893b0 --- /dev/null +++ b/pylabrobot/capabilities/fan_control/backend.py @@ -0,0 +1,15 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.device import DeviceBackend + + +class FanBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for fan devices.""" + + @abstractmethod + async def turn_on(self, intensity: int) -> None: + """Run the fan at the given intensity (0-100).""" + + @abstractmethod + async def turn_off(self) -> None: + """Stop the fan.""" diff --git a/pylabrobot/only_fans/fan.py b/pylabrobot/capabilities/fan_control/fan_control.py similarity index 52% rename from pylabrobot/only_fans/fan.py rename to pylabrobot/capabilities/fan_control/fan_control.py index c34e784a9ee..76f9eaa6b05 100644 --- a/pylabrobot/only_fans/fan.py +++ b/pylabrobot/capabilities/fan_control/fan_control.py @@ -1,37 +1,33 @@ import asyncio -from pylabrobot.machines.machine import Machine +from pylabrobot.capabilities.capability import Capability from .backend import FanBackend -class Fan(Machine): - """ - Front end for Fans. - """ +class FanControlCapability(Capability): + """Fan control capability.""" def __init__(self, backend: FanBackend): super().__init__(backend=backend) - self.backend: FanBackend = backend # fix type - - async def stop(self): - await self.backend.turn_off() - await super().stop() + self.backend: FanBackend = backend async def turn_on(self, intensity: int, duration=None): - """Run the fan + """Run the fan. Args: - intensity: integer percent between 0 and 100 - duration: time to run the fan for. If None, run until `turn_off` is called. + intensity: integer percent between 0 and 100. + duration: time to run the fan for in seconds. If None, run until turn_off is called. """ - await self.backend.turn_on(intensity=intensity) - if duration is not None: await asyncio.sleep(duration) await self.backend.turn_off() async def turn_off(self): - """Turn the fan off, but do not close the connection.""" + """Turn the fan off.""" + await self.backend.turn_off() + + async def _on_stop(self): await self.backend.turn_off() + await super()._on_stop() diff --git a/pylabrobot/capabilities/humidity_controlling/__init__.py b/pylabrobot/capabilities/humidity_controlling/__init__.py new file mode 100644 index 00000000000..ea02818a921 --- /dev/null +++ b/pylabrobot/capabilities/humidity_controlling/__init__.py @@ -0,0 +1,2 @@ +from .backend import HumidityControllerBackend +from .humidity_controller import HumidityControlCapability diff --git a/pylabrobot/capabilities/humidity_controlling/backend.py b/pylabrobot/capabilities/humidity_controlling/backend.py new file mode 100644 index 00000000000..6b3c80ebcd1 --- /dev/null +++ b/pylabrobot/capabilities/humidity_controlling/backend.py @@ -0,0 +1,20 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.device import DeviceBackend + + +class HumidityControllerBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for humidity controllers.""" + + @property + @abstractmethod + def supports_humidity_control(self) -> bool: + """Whether this backend can set humidity (vs read-only monitoring).""" + + @abstractmethod + async def set_humidity(self, humidity: float): + """Set the target humidity as a fraction 0.0-1.0.""" + + @abstractmethod + async def get_current_humidity(self) -> float: + """Get the current humidity as a fraction 0.0-1.0.""" diff --git a/pylabrobot/capabilities/humidity_controlling/humidity_controller.py b/pylabrobot/capabilities/humidity_controlling/humidity_controller.py new file mode 100644 index 00000000000..066a4e7de94 --- /dev/null +++ b/pylabrobot/capabilities/humidity_controlling/humidity_controller.py @@ -0,0 +1,28 @@ +from pylabrobot.capabilities.capability import Capability + +from .backend import HumidityControllerBackend + + +class HumidityControlCapability(Capability): + """Humidity control capability.""" + + def __init__(self, backend: HumidityControllerBackend): + super().__init__(backend=backend) + self.backend: HumidityControllerBackend = backend + + async def set_humidity(self, humidity: float): + """Set the target humidity as a fraction 0.0-1.0. + + Raises: + ValueError: If the backend does not support humidity control. + """ + if not self.backend.supports_humidity_control: + raise ValueError("Backend does not support humidity control (read-only).") + await self.backend.set_humidity(humidity) + + async def get_humidity(self) -> float: + """Get the current humidity as a fraction 0.0-1.0.""" + return await self.backend.get_current_humidity() + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/capabilities/microscopy/__init__.py b/pylabrobot/capabilities/microscopy/__init__.py new file mode 100644 index 00000000000..8084e92f272 --- /dev/null +++ b/pylabrobot/capabilities/microscopy/__init__.py @@ -0,0 +1,18 @@ +from .backend import MicroscopyBackend +from .microscopy import ( + MicroscopyCapability, + evaluate_focus_nvmg_sobel, + fraction_overexposed, + max_pixel_at_fraction, +) +from .standard import ( + AutoExposure, + AutoFocus, + Exposure, + FocalPosition, + Gain, + Image, + ImagingMode, + ImagingResult, + Objective, +) diff --git a/pylabrobot/capabilities/microscopy/backend.py b/pylabrobot/capabilities/microscopy/backend.py new file mode 100644 index 00000000000..3a6a7185315 --- /dev/null +++ b/pylabrobot/capabilities/microscopy/backend.py @@ -0,0 +1,44 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.capabilities.microscopy.standard import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.device import DeviceBackend +from pylabrobot.resources.plate import Plate + + +class MicroscopyBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for microscopy devices.""" + + @abstractmethod + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + ) -> ImagingResult: + """Capture an image at the specified well position. + + Args: + row: 0-indexed row of the well. + column: 0-indexed column of the well. + mode: Imaging mode (brightfield, fluorescence channel, etc.). + objective: Objective lens to use. + exposure_time: Exposure time in ms, or ``"machine-auto"`` for automatic. + focal_height: Focal height in mm, or ``"machine-auto"`` for automatic. + gain: Gain value, or ``"machine-auto"`` for automatic. + plate: The plate being imaged (used for geometry/labware parameters). + + Returns: + An :class:`ImagingResult` containing the captured image(s) and metadata. + """ diff --git a/pylabrobot/capabilities/microscopy/chatterbox.py b/pylabrobot/capabilities/microscopy/chatterbox.py new file mode 100644 index 00000000000..31fa7c8735f --- /dev/null +++ b/pylabrobot/capabilities/microscopy/chatterbox.py @@ -0,0 +1,50 @@ +from pylabrobot.capabilities.microscopy.backend import MicroscopyBackend +from pylabrobot.capabilities.microscopy.standard import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources.plate import Plate + +try: + import numpy as np # type: ignore + + HAS_NUMPY = True +except ImportError: + np = None # type: ignore[assignment] + HAS_NUMPY = False + + +class MicroscopyChatterboxBackend(MicroscopyBackend): + """Mock microscopy backend for testing.""" + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + ) -> ImagingResult: + if HAS_NUMPY: + image = np.zeros((512, 512), dtype=np.uint16) + else: + image = [[0] * 512 for _ in range(512)] # type: ignore + + return ImagingResult( + images=[image], + exposure_time=exposure_time if isinstance(exposure_time, (int, float)) else 10.0, + focal_height=focal_height if isinstance(focal_height, (int, float)) else 0.0, + ) diff --git a/pylabrobot/capabilities/microscopy/microscopy.py b/pylabrobot/capabilities/microscopy/microscopy.py new file mode 100644 index 00000000000..4def712b33e --- /dev/null +++ b/pylabrobot/capabilities/microscopy/microscopy.py @@ -0,0 +1,325 @@ +import logging +import math +import time +from typing import Dict, Tuple, Union, cast + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.capabilities.microscopy.standard import ( + AutoExposure, + AutoFocus, + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +from .backend import MicroscopyBackend + +try: + import numpy as np # type: ignore + + HAS_NUMPY = True +except ImportError: + np = None # type: ignore[assignment] + HAS_NUMPY = False + +logger = logging.getLogger(__name__) + + +async def _golden_ratio_search(func, a: float, b: float, tol: float, timeout: float) -> float: + """Golden ratio search to maximize a unimodal function over [a, b].""" + phi = (1 + 5**0.5) / 2 + c = b - (b - a) / phi + d = a + (b - a) / phi + cache: Dict[float, float] = {} + + async def cached_func(x: float) -> float: + x = round(x / tol) * tol + if x not in cache: + cache[x] = await func(x) + return cache[x] + + t0 = time.time() + while abs(b - a) > tol: + if (await cached_func(c)) > (await cached_func(d)): + b = d + else: + a = c + c = b - (b - a) / phi + d = a + (b - a) / phi + if time.time() - t0 > timeout: + raise TimeoutError("Timeout while searching for optimal focus position") + + return (b + a) / 2 + + +class MicroscopyCapability(Capability): + """Microscopy imaging capability. + + Provides high-level image capture with support for auto-exposure and auto-focus. + """ + + def __init__(self, backend: MicroscopyBackend): + super().__init__(backend=backend) + self.backend: MicroscopyBackend = backend + + def _resolve_well(self, well: Union[Well, Tuple[int, int]]) -> Tuple[int, int]: + """Convert a Well or (row, col) tuple to (row, col) indices.""" + if isinstance(well, tuple): + return well + plate = cast(Plate, well.parent) + idx = plate.index_of_item(well) + if idx is None: + raise ValueError(f"Well {well} not found in plate {well.parent}") + row, column = divmod(idx, plate.num_items_x) + return row, column + + async def _capture_auto_exposure( + self, + well: Union[Well, Tuple[int, int]], + mode: ImagingMode, + objective: Objective, + auto_exposure: AutoExposure, + focal_height: float, + gain: float, + plate: Plate, + **backend_kwargs, + ) -> ImagingResult: + """Capture with iterative auto-exposure using weighted binary search.""" + + def _rms_split(low: float, high: float) -> float: + if low == high: + return low + return math.sqrt((low**2 + high**2) / 2) + + low, high = auto_exposure.low, auto_exposure.high + rounds = 0 + while high - low > 1e-3: + if auto_exposure.max_rounds is not None and rounds >= auto_exposure.max_rounds: + raise ValueError("Exceeded maximum number of auto-exposure rounds") + rounds += 1 + + p = _rms_split(low, high) + res = await self.capture( + well=well, + mode=mode, + objective=objective, + exposure_time=p, + focal_height=focal_height, + gain=gain, + plate=plate, + **backend_kwargs, + ) + assert len(res.images) == 1, "Expected exactly one image for auto-exposure" + evaluation = await auto_exposure.evaluate_exposure(res.images[0]) + + if evaluation == "good": + return res + if evaluation == "lower": + high = p + elif evaluation == "higher": + low = p + else: + raise ValueError(f"Unexpected evaluation result: {evaluation}") + + raise RuntimeError("Failed to find a good exposure time.") + + async def _capture_auto_focus( + self, + well: Union[Well, Tuple[int, int]], + mode: ImagingMode, + objective: Objective, + exposure_time: float, + auto_focus: AutoFocus, + gain: float, + plate: Plate, + **backend_kwargs, + ) -> ImagingResult: + """Capture with golden-ratio auto-focus search.""" + + async def local_capture(focal_height: float) -> ImagingResult: + return await self.capture( + well=well, + mode=mode, + objective=objective, + exposure_time=exposure_time, + focal_height=focal_height, + gain=gain, + plate=plate, + **backend_kwargs, + ) + + async def capture_and_evaluate(focal_height: float) -> float: + res = await local_capture(focal_height) + return auto_focus.evaluate_focus(res.images[0]) + + best_focal_height = await _golden_ratio_search( + func=capture_and_evaluate, + a=auto_focus.low, + b=auto_focus.high, + tol=auto_focus.tolerance, + timeout=auto_focus.timeout, + ) + return await local_capture(best_focal_height) + + @need_capability_ready + async def capture( + self, + well: Union[Well, Tuple[int, int]], + mode: ImagingMode, + objective: Objective, + plate: Plate, + exposure_time: Union[Exposure, AutoExposure] = "machine-auto", + focal_height: Union[FocalPosition, AutoFocus] = "machine-auto", + gain: Gain = "machine-auto", + **backend_kwargs, + ) -> ImagingResult: + """Capture an image of a well. + + Args: + well: A :class:`Well` instance or a ``(row, column)`` tuple (0-indexed). + mode: Imaging mode (brightfield, fluorescence channel, etc.). + objective: Objective lens to use. + plate: The plate being imaged. + exposure_time: Exposure time in ms, :class:`AutoExposure`, or ``"machine-auto"``. + focal_height: Focal height in mm, :class:`AutoFocus`, or ``"machine-auto"``. + gain: Gain value or ``"machine-auto"``. + **backend_kwargs: Additional keyword arguments passed to the backend. + + Returns: + An :class:`ImagingResult` with captured image(s) and metadata. + """ + if isinstance(exposure_time, AutoExposure): + if not isinstance(focal_height, (int, float)): + raise ValueError("Focal height must be a number when using AutoExposure") + if not isinstance(gain, (int, float)): + raise ValueError("Gain must be a number when using AutoExposure") + return await self._capture_auto_exposure( + well=well, + mode=mode, + objective=objective, + auto_exposure=exposure_time, + focal_height=focal_height, + gain=gain, + plate=plate, + **backend_kwargs, + ) + + if isinstance(focal_height, AutoFocus): + if not isinstance(exposure_time, (int, float)): + raise ValueError("Exposure time must be a number when using AutoFocus") + if not isinstance(gain, (int, float)): + raise ValueError("Gain must be a number when using AutoFocus") + return await self._capture_auto_focus( + well=well, + mode=mode, + objective=objective, + exposure_time=exposure_time, + auto_focus=focal_height, + gain=gain, + plate=plate, + **backend_kwargs, + ) + + row, column = self._resolve_well(well) + return await self.backend.capture( + row=row, + column=column, + mode=mode, + objective=objective, + exposure_time=exposure_time, + focal_height=focal_height, + gain=gain, + plate=plate, + **backend_kwargs, + ) + + async def _on_stop(self): + await super()._on_stop() + + +# --------------------------------------------------------------------------- +# Exposure / focus evaluation helpers +# --------------------------------------------------------------------------- + +try: + import cv2 as _cv2 # type: ignore + + _CV2_AVAILABLE = True +except ImportError as _e: + _cv2 = None # type: ignore + _CV2_AVAILABLE = False + _CV2_IMPORT_ERROR = _e + + +def max_pixel_at_fraction(fraction: float, margin: float): + """Return an evaluate_exposure callback targeting *fraction* of max pixel value. + + Args: + fraction: desired ratio of actual max pixel to theoretical max (e.g. 0.8). + margin: acceptable error as a fraction of theoretical max (e.g. 0.05). + """ + if np is None: + raise ImportError("numpy is required for max_pixel_at_fraction") + + async def evaluate_exposure(im): + array = np.array(im, dtype=np.float32) + value = np.max(array) - (255.0 * fraction) + margin_value = 255.0 * margin + if abs(value) <= margin_value: + return "good" + return "lower" if value > 0 else "higher" + + return evaluate_exposure + + +def fraction_overexposed(fraction: float, margin: float, max_pixel_value: int = 255): + """Return an evaluate_exposure callback targeting a fraction of saturated pixels. + + Args: + fraction: desired fraction of overexposed pixels (e.g. 0.005). + margin: acceptable error on that fraction (e.g. 0.001). + max_pixel_value: threshold for "overexposed" (default 255). + """ + if np is None: + raise ImportError("numpy is required for fraction_overexposed") + + async def evaluate_exposure(im): + arr = np.asarray(im, dtype=np.uint8) + actual_fraction = np.count_nonzero(arr > max_pixel_value) / arr.size + lower_bound, upper_bound = fraction - margin, fraction + margin + if lower_bound <= actual_fraction <= upper_bound: + return "good" + return "lower" if (actual_fraction - fraction) > 0 else "higher" + + return evaluate_exposure + + +def evaluate_focus_nvmg_sobel(image) -> float: + """Evaluate focus via Normalized Variance of Gradient Magnitude (Sobel). + + Uses the center 50 % of the image to avoid edge effects. + """ + if not _CV2_AVAILABLE: + raise RuntimeError( + f"cv2 needs to be installed for auto focus. Import error: {_CV2_IMPORT_ERROR}" + ) + if np is None: + raise ImportError("numpy is required for evaluate_focus_nvmg_sobel") + + np_image = np.array(image, dtype=np.float64) + height, width = np_image.shape[:2] + crop_h, crop_w = height // 4, width // 4 + np_image = np_image[crop_h : height - crop_h, crop_w : width - crop_w] + + sobel_x = _cv2.Sobel(np_image, _cv2.CV_64F, 1, 0, ksize=3) + sobel_y = _cv2.Sobel(np_image, _cv2.CV_64F, 0, 1, ksize=3) + gradient_magnitude = np.sqrt(sobel_x**2 + sobel_y**2) + + mean_gm = np.mean(gradient_magnitude) + var_gm = np.var(gradient_magnitude) + return float(var_gm / (mean_gm + 1e-6)) diff --git a/pylabrobot/capabilities/microscopy/microscopy_tests.py b/pylabrobot/capabilities/microscopy/microscopy_tests.py new file mode 100644 index 00000000000..4a71aa6663f --- /dev/null +++ b/pylabrobot/capabilities/microscopy/microscopy_tests.py @@ -0,0 +1,193 @@ +"""Tests for MicroscopyCapability.""" + +import unittest +from typing import List, Tuple + +import pytest + +pytest.importorskip("numpy") + +import numpy # noqa: E402 + +from pylabrobot.capabilities.microscopy.backend import MicroscopyBackend +from pylabrobot.capabilities.microscopy.chatterbox import MicroscopyChatterboxBackend +from pylabrobot.capabilities.microscopy.microscopy import MicroscopyCapability +from pylabrobot.capabilities.microscopy.standard import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.device import Device +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import Well, WellBottomType + + +def _test_plate() -> Plate: + return Plate( + name="test_plate", + size_x=127.6, + size_y=85.75, + size_z=13.83, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=8, + dx=10.9, + dy=7.96, + dz=1.5, + item_dx=9.0, + item_dy=9.0, + size_x=6.8, + size_y=6.8, + size_z=10.67, + bottom_type=WellBottomType.FLAT, + material_z_thickness=0.17, + max_volume=350.0, + ), + ) + + +class RecordingMicroscopyBackend(MicroscopyBackend): + """Backend that records all capture calls for assertion.""" + + def __init__(self): + self.calls: List[Tuple] = [] + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + ) -> ImagingResult: + self.calls.append((row, column, mode, objective, exposure_time, focal_height, gain)) + return ImagingResult( + images=[numpy.zeros((4, 4), dtype=int)], + exposure_time=exposure_time if isinstance(exposure_time, (int, float)) else 10.0, + focal_height=focal_height if isinstance(focal_height, (int, float)) else 0.0, + ) + + +class _TestMicroscope(Device): + def __init__(self, backend: MicroscopyBackend): + super().__init__(backend=backend) + self._backend = backend + self.microscopy = MicroscopyCapability(backend=backend) + self._capabilities = [self.microscopy] + + +class TestMicroscopyCapability(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = RecordingMicroscopyBackend() + self.device = _TestMicroscope(backend=self.backend) + await self.device.setup() + self.plate = _test_plate() + + async def asyncTearDown(self): + await self.device.stop() + + async def test_capture_with_tuple_well(self): + result = await self.device.microscopy.capture( + well=(2, 5), + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_10X_PL_FL, + plate=self.plate, + exposure_time=10.0, + focal_height=1.5, + gain=1.0, + ) + self.assertEqual(len(self.backend.calls), 1) + row, col, mode, obj, exp, fh, g = self.backend.calls[0] + self.assertEqual(row, 2) + self.assertEqual(col, 5) + self.assertEqual(mode, ImagingMode.BRIGHTFIELD) + self.assertEqual(obj, Objective.O_10X_PL_FL) + self.assertAlmostEqual(exp, 10.0) + self.assertAlmostEqual(fh, 1.5) + self.assertAlmostEqual(g, 1.0) + self.assertEqual(len(result.images), 1) + + async def test_capture_with_well_object(self): + well = self.plate.get_well("C7") + await self.device.microscopy.capture( + well=well, + mode=ImagingMode.DAPI, + objective=Objective.O_20X_PL_FL, + plate=self.plate, + exposure_time=5.0, + focal_height=2.0, + gain=0.5, + ) + self.assertEqual(len(self.backend.calls), 1) + row, col, *_ = self.backend.calls[0] + # index_of_item for C7 = 50, divmod(50, 12) = (4, 2) + expected_idx = self.plate.index_of_item(well) + assert expected_idx is not None + expected_row, expected_col = divmod(expected_idx, self.plate.num_items_x) + self.assertEqual(row, expected_row) + self.assertEqual(col, expected_col) + + async def test_capture_machine_auto(self): + await self.device.microscopy.capture( + well=(0, 0), + mode=ImagingMode.GFP, + objective=Objective.O_4X_PL_FL, + plate=self.plate, + ) + self.assertEqual(len(self.backend.calls), 1) + _, _, _, _, exp, fh, g = self.backend.calls[0] + self.assertEqual(exp, "machine-auto") + self.assertEqual(fh, "machine-auto") + self.assertEqual(g, "machine-auto") + + async def test_capture_requires_setup(self): + backend = RecordingMicroscopyBackend() + cap = MicroscopyCapability(backend=backend) + with self.assertRaises(RuntimeError): + await cap.capture( + well=(0, 0), + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_4X_PL_FL, + plate=self.plate, + ) + + +class TestChatterboxBackend(unittest.IsolatedAsyncioTestCase): + async def test_chatterbox_capture(self): + backend = MicroscopyChatterboxBackend() + device = _TestMicroscope(backend=backend) + await device.setup() + + plate = _test_plate() + result = await device.microscopy.capture( + well=(0, 0), + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_4X_PL_FL, + plate=plate, + exposure_time=10.0, + focal_height=1.0, + gain=1.0, + ) + self.assertEqual(len(result.images), 1) + self.assertAlmostEqual(result.exposure_time, 10.0) + self.assertAlmostEqual(result.focal_height, 1.0) + + await device.stop() + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/microscopy/standard.py b/pylabrobot/capabilities/microscopy/standard.py new file mode 100644 index 00000000000..284184432bb --- /dev/null +++ b/pylabrobot/capabilities/microscopy/standard.py @@ -0,0 +1,130 @@ +import enum +import sys +from dataclasses import dataclass +from typing import Awaitable, Callable, List, Literal, Union + +if sys.version_info >= (3, 10): + from typing import TypeAlias +else: + from typing_extensions import TypeAlias + +try: + import numpy.typing as npt # type: ignore + + Image: TypeAlias = npt.NDArray +except ImportError: + Image: TypeAlias = object # type: ignore + + +class Objective(enum.Enum): + # Cytation objectives + O_1_25X_PL_APO = enum.auto() + O_2X_PL_ACH_Motic = enum.auto() + O_2_5X_PL_ACH_Meiji = enum.auto() + O_2_5X_FL_Zeiss = enum.auto() + O_2_5X_N_PLAN = enum.auto() + O_4X_PL_FL = enum.auto() + O_4X_PL_ACH = enum.auto() + O_4X_PL_FL_Phase = enum.auto() + O_10X_PL_FL = enum.auto() + O_10X_PL_FL_Phase = enum.auto() + O_20X_PL_FL = enum.auto() + O_20X_PL_ACH = enum.auto() + O_20X_PL_FL_Phase = enum.auto() + O_20X_PL_APO = enum.auto() + O_40X_PL_FL = enum.auto() + O_40X_PL_ACH = enum.auto() + O_40X_PL_APO = enum.auto() + O_40X_PL_FL_Phase = enum.auto() + O_60X_PL_FL = enum.auto() + O_60X_OIL_PL_FL = enum.auto() + O_60X_OIL_PL_APO = enum.auto() + O_100X_OIL_PL_FL = enum.auto() + O_100X_OIL_PL_APO = enum.auto() + + @property + def magnification(self) -> float: + return { + Objective.O_1_25X_PL_APO: 1.25, + Objective.O_2X_PL_ACH_Motic: 2, + Objective.O_2_5X_PL_ACH_Meiji: 2.5, + Objective.O_2_5X_FL_Zeiss: 2.5, + Objective.O_2_5X_N_PLAN: 2.5, + Objective.O_4X_PL_FL: 4, + Objective.O_4X_PL_ACH: 4, + Objective.O_4X_PL_FL_Phase: 4, + Objective.O_10X_PL_FL: 10, + Objective.O_10X_PL_FL_Phase: 10, + Objective.O_20X_PL_FL: 20, + Objective.O_20X_PL_ACH: 20, + Objective.O_20X_PL_FL_Phase: 20, + Objective.O_20X_PL_APO: 20, + Objective.O_40X_PL_FL: 40, + Objective.O_40X_PL_ACH: 40, + Objective.O_40X_PL_APO: 40, + Objective.O_40X_PL_FL_Phase: 40, + Objective.O_60X_PL_FL: 60, + Objective.O_60X_OIL_PL_FL: 60, + Objective.O_60X_OIL_PL_APO: 60, + Objective.O_100X_OIL_PL_FL: 100, + Objective.O_100X_OIL_PL_APO: 100, + }[self] + + +class ImagingMode(enum.Enum): + BRIGHTFIELD = enum.auto() + PHASE_CONTRAST = enum.auto() + COLOR_BRIGHTFIELD = enum.auto() + + C377_647 = enum.auto() + C400_647 = enum.auto() + C469_593 = enum.auto() + ACRIDINE_ORANGE = enum.auto() + CFP = enum.auto() + CFP_FRET_V2 = enum.auto() + CFP_YFP_FRET = enum.auto() + CFP_YFP_FRET_V2 = enum.auto() + CHLOROPHYLL_A = enum.auto() + CY5 = enum.auto() + CY5_5 = enum.auto() + CY7 = enum.auto() + DAPI = enum.auto() + FITC = enum.auto() + GFP = enum.auto() + GFP_CY5 = enum.auto() + OXIDIZED_ROGFP2 = enum.auto() + PROPIDIUM_IODIDE = enum.auto() + RFP = enum.auto() + RFP_CY5 = enum.auto() + TAG_BFP = enum.auto() + TEXAS_RED = enum.auto() + YFP = enum.auto() + + +Exposure = Union[float, Literal["machine-auto"]] +FocalPosition = Union[float, Literal["machine-auto"]] +Gain = Union[float, Literal["machine-auto"]] + + +@dataclass +class AutoExposure: + evaluate_exposure: Callable[[Image], Awaitable[Literal["higher", "lower", "good"]]] + max_rounds: int + low: float + high: float + + +@dataclass +class AutoFocus: + evaluate_focus: Callable[[Image], float] + timeout: float + low: float + high: float + tolerance: float = 0.001 # 1 micron + + +@dataclass +class ImagingResult: + images: List[Image] + exposure_time: float + focal_height: float diff --git a/pylabrobot/capabilities/peeling/__init__.py b/pylabrobot/capabilities/peeling/__init__.py new file mode 100644 index 00000000000..20e3598871b --- /dev/null +++ b/pylabrobot/capabilities/peeling/__init__.py @@ -0,0 +1,2 @@ +from .backend import PeelerBackend +from .peeling import PeelingCapability diff --git a/pylabrobot/capabilities/peeling/backend.py b/pylabrobot/capabilities/peeling/backend.py new file mode 100644 index 00000000000..453b929ee2c --- /dev/null +++ b/pylabrobot/capabilities/peeling/backend.py @@ -0,0 +1,15 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.device import DeviceBackend + + +class PeelerBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for peeling devices.""" + + @abstractmethod + async def peel(self): + """Run an automated de-seal cycle.""" + + @abstractmethod + async def restart(self): + """Restart the peeler machine.""" diff --git a/pylabrobot/peeling/peeler.py b/pylabrobot/capabilities/peeling/peeling.py similarity index 58% rename from pylabrobot/peeling/peeler.py rename to pylabrobot/capabilities/peeling/peeling.py index 4d7fba43b44..0f7b0e47c12 100644 --- a/pylabrobot/peeling/peeler.py +++ b/pylabrobot/capabilities/peeling/peeling.py @@ -1,17 +1,22 @@ -from pylabrobot.machines import Machine +from pylabrobot.capabilities.capability import Capability from .backend import PeelerBackend -class Peeler(Machine): - """A microplate peeler""" +class PeelingCapability(Capability): + """Peeling capability.""" def __init__(self, backend: PeelerBackend): super().__init__(backend=backend) self.backend: PeelerBackend = backend async def peel(self, **backend_kwargs): + """Run an automated de-seal cycle.""" return await self.backend.peel(**backend_kwargs) async def restart(self, **backend_kwargs): + """Restart the peeler.""" return await self.backend.restart(**backend_kwargs) + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/capabilities/plate_reading/__init__.py b/pylabrobot/capabilities/plate_reading/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/__init__.py @@ -0,0 +1 @@ + diff --git a/pylabrobot/capabilities/plate_reading/absorbance/__init__.py b/pylabrobot/capabilities/plate_reading/absorbance/__init__.py new file mode 100644 index 00000000000..715f3f3b4fb --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/absorbance/__init__.py @@ -0,0 +1,3 @@ +from .absorbance import AbsorbanceCapability +from .backend import AbsorbanceBackend +from .standard import AbsorbanceResult diff --git a/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py b/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py new file mode 100644 index 00000000000..29c195c66cd --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import logging +from typing import List, Optional + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +from .backend import AbsorbanceBackend + +logger = logging.getLogger(__name__) + + +class AbsorbanceCapability(Capability): + """Absorbance plate reading capability.""" + + def __init__(self, backend: AbsorbanceBackend): + super().__init__(backend=backend) + self.backend: AbsorbanceBackend = backend + + @need_capability_ready + async def read( + self, + plate: Plate, + wavelength: int, + wells: Optional[List[Well]] = None, + **backend_kwargs, + ) -> List[AbsorbanceResult]: + """Read absorbance from a plate. + + Args: + plate: The plate to read. + wavelength: Wavelength in nm. + wells: Wells to measure. Defaults to all wells in the plate. + **backend_kwargs: Additional keyword arguments passed to the backend. + + Returns: + A list of :class:`AbsorbanceResult` (typically length 1). + """ + if wells is None: + wells = plate.get_all_items() + return await self.backend.read_absorbance( + plate=plate, wells=wells, wavelength=wavelength, **backend_kwargs + ) diff --git a/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py new file mode 100644 index 00000000000..186dbb07364 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py @@ -0,0 +1,130 @@ +"""Tests for AbsorbanceCapability.""" + +import unittest +from typing import List, Optional, Tuple + +from pylabrobot.capabilities.plate_reading.absorbance.absorbance import AbsorbanceCapability +from pylabrobot.capabilities.plate_reading.absorbance.backend import AbsorbanceBackend +from pylabrobot.capabilities.plate_reading.absorbance.chatterbox import ( + AbsorbanceChatterboxBackend, +) +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.device import Device +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import Well, WellBottomType + + +def _test_plate() -> Plate: + return Plate( + name="test_plate", + size_x=127.6, + size_y=85.75, + size_z=13.83, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=8, + dx=10.9, + dy=7.96, + dz=1.5, + item_dx=9.0, + item_dy=9.0, + size_x=6.8, + size_y=6.8, + size_z=10.67, + bottom_type=WellBottomType.FLAT, + material_z_thickness=0.17, + max_volume=350.0, + ), + ) + + +class RecordingAbsorbanceBackend(AbsorbanceBackend): + """Backend that records all read_absorbance calls for assertion.""" + + def __init__(self): + self.calls: List[Tuple] = [] + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def read_absorbance( + self, plate: Plate, wells: List[Well], wavelength: int + ) -> List[AbsorbanceResult]: + self.calls.append((plate, wells, wavelength)) + data: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + for well in wells: + r, c = well.get_row(), well.get_column() + data[r][c] = 0.5 + return [AbsorbanceResult(data=data, wavelength=wavelength, temperature=None, timestamp=0.0)] + + +class _TestDevice(Device): + def __init__(self, backend: AbsorbanceBackend): + super().__init__(backend=backend) + self._backend = backend + self.absorbance = AbsorbanceCapability(backend=backend) + self._capabilities = [self.absorbance] + + +class TestAbsorbanceCapability(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = RecordingAbsorbanceBackend() + self.device = _TestDevice(backend=self.backend) + await self.device.setup() + self.plate = _test_plate() + + async def asyncTearDown(self): + await self.device.stop() + + async def test_read_with_wells(self): + wells = [self.plate.get_well("A1"), self.plate.get_well("B2")] + results = await self.device.absorbance.read(plate=self.plate, wavelength=450, wells=wells) + self.assertEqual(len(self.backend.calls), 1) + _, recorded_wells, recorded_wl = self.backend.calls[0] + self.assertEqual(recorded_wells, wells) + self.assertEqual(recorded_wl, 450) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].wavelength, 450) + + async def test_read_all_wells(self): + results = await self.device.absorbance.read(plate=self.plate, wavelength=600) + self.assertEqual(len(self.backend.calls), 1) + _, recorded_wells, _ = self.backend.calls[0] + self.assertEqual(len(recorded_wells), 96) + self.assertEqual(results[0].wavelength, 600) + + async def test_read_requires_setup(self): + backend = RecordingAbsorbanceBackend() + cap = AbsorbanceCapability(backend=backend) + with self.assertRaises(RuntimeError): + await cap.read(plate=self.plate, wavelength=450) + + +class TestAbsorbanceChatterbox(unittest.IsolatedAsyncioTestCase): + async def test_chatterbox_read(self): + backend = AbsorbanceChatterboxBackend() + device = _TestDevice(backend=backend) + await device.setup() + + plate = _test_plate() + wells = [plate.get_well("A1"), plate.get_well("H12")] + results = await device.absorbance.read(plate=plate, wavelength=450, wells=wells) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].wavelength, 450) + # Only requested wells should have data + self.assertIsNotNone(results[0].data[0][0]) # A1 + self.assertIsNotNone(results[0].data[7][11]) # H12 + self.assertIsNone(results[0].data[0][1]) # A2 not requested + + await device.stop() + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/plate_reading/absorbance/backend.py b/pylabrobot/capabilities/plate_reading/absorbance/backend.py new file mode 100644 index 00000000000..dc08216e883 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/absorbance/backend.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import List + +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.device import DeviceBackend +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + + +class AbsorbanceBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for absorbance plate reading.""" + + @abstractmethod + async def read_absorbance( + self, plate: Plate, wells: List[Well], wavelength: int + ) -> List[AbsorbanceResult]: + """Read absorbance for the given wells. + + Args: + plate: The plate to read. + wells: Wells to measure. + wavelength: Wavelength in nm. + + Returns: + A list of :class:`AbsorbanceResult` (typically length 1). + """ diff --git a/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py b/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py new file mode 100644 index 00000000000..c8f2ea5ca15 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py @@ -0,0 +1,34 @@ +import time +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.absorbance.backend import AbsorbanceBackend +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.capabilities.plate_reading.utils import mask_wells +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + + +class AbsorbanceChatterboxBackend(AbsorbanceBackend): + """Mock absorbance backend for testing.""" + + def __init__(self): + self.dummy_absorbance: List[List[Optional[float]]] = [[0.0] * 12 for _ in range(8)] + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def read_absorbance( + self, plate: Plate, wells: List[Well], wavelength: int + ) -> List[AbsorbanceResult]: + data = mask_wells(self.dummy_absorbance, wells, plate) + return [ + AbsorbanceResult( + data=data, + wavelength=wavelength, + temperature=None, + timestamp=time.time(), + ) + ] diff --git a/pylabrobot/capabilities/plate_reading/absorbance/standard.py b/pylabrobot/capabilities/plate_reading/absorbance/standard.py new file mode 100644 index 00000000000..c1eda696f34 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/absorbance/standard.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import dataclasses +from typing import List, Optional + + +@dataclasses.dataclass +class AbsorbanceResult: + """Result of an absorbance measurement. + + Attributes: + data: 2D array indexed [row][col]. ``None`` for unmeasured wells. + wavelength: Wavelength in nm. + temperature: Temperature in °C, or ``None`` if not available. + timestamp: Unix timestamp of the measurement. + """ + + data: List[List[Optional[float]]] + wavelength: int + temperature: Optional[float] + timestamp: float diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/__init__.py b/pylabrobot/capabilities/plate_reading/fluorescence/__init__.py new file mode 100644 index 00000000000..248793e0485 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/fluorescence/__init__.py @@ -0,0 +1,3 @@ +from .backend import FluorescenceBackend +from .fluorescence import FluorescenceCapability +from .standard import FluorescenceResult diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/backend.py b/pylabrobot/capabilities/plate_reading/fluorescence/backend.py new file mode 100644 index 00000000000..02494981d7e --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/fluorescence/backend.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import List + +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.device import DeviceBackend +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + + +class FluorescenceBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for fluorescence plate reading.""" + + @abstractmethod + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[FluorescenceResult]: + """Read fluorescence for the given wells. + + Args: + plate: The plate to read. + wells: Wells to measure. + excitation_wavelength: Excitation wavelength in nm. + emission_wavelength: Emission wavelength in nm. + focal_height: Focal height in mm. + + Returns: + A list of :class:`FluorescenceResult` (typically length 1). + """ diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py b/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py new file mode 100644 index 00000000000..3324b96a8da --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py @@ -0,0 +1,40 @@ +import time +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.fluorescence.backend import FluorescenceBackend +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.capabilities.plate_reading.utils import mask_wells +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + + +class FluorescenceChatterboxBackend(FluorescenceBackend): + """Mock fluorescence backend for testing.""" + + def __init__(self): + self.dummy_fluorescence: List[List[Optional[float]]] = [[0.0] * 12 for _ in range(8)] + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[FluorescenceResult]: + data = mask_wells(self.dummy_fluorescence, wells, plate) + return [ + FluorescenceResult( + data=data, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + temperature=None, + timestamp=time.time(), + ) + ] diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py new file mode 100644 index 00000000000..1f43e955567 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import logging +from typing import List, Optional + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +from .backend import FluorescenceBackend + +logger = logging.getLogger(__name__) + + +class FluorescenceCapability(Capability): + """Fluorescence plate reading capability.""" + + def __init__(self, backend: FluorescenceBackend): + super().__init__(backend=backend) + self.backend: FluorescenceBackend = backend + + @need_capability_ready + async def read( + self, + plate: Plate, + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + wells: Optional[List[Well]] = None, + **backend_kwargs, + ) -> List[FluorescenceResult]: + """Read fluorescence from a plate. + + Args: + plate: The plate to read. + excitation_wavelength: Excitation wavelength in nm. + emission_wavelength: Emission wavelength in nm. + focal_height: Focal height in mm. + wells: Wells to measure. Defaults to all wells in the plate. + **backend_kwargs: Additional keyword arguments passed to the backend. + + Returns: + A list of :class:`FluorescenceResult` (typically length 1). + """ + if wells is None: + wells = plate.get_all_items() + return await self.backend.read_fluorescence( + plate=plate, + wells=wells, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + focal_height=focal_height, + **backend_kwargs, + ) diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py new file mode 100644 index 00000000000..2b05596146c --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py @@ -0,0 +1,164 @@ +"""Tests for FluorescenceCapability.""" + +import unittest +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.fluorescence.backend import FluorescenceBackend +from pylabrobot.capabilities.plate_reading.fluorescence.chatterbox import ( + FluorescenceChatterboxBackend, +) +from pylabrobot.capabilities.plate_reading.fluorescence.fluorescence import FluorescenceCapability +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.device import Device +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import Well, WellBottomType + + +def _test_plate() -> Plate: + return Plate( + name="test_plate", + size_x=127.6, + size_y=85.75, + size_z=13.83, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=8, + dx=10.9, + dy=7.96, + dz=1.5, + item_dx=9.0, + item_dy=9.0, + size_x=6.8, + size_y=6.8, + size_z=10.67, + bottom_type=WellBottomType.FLAT, + material_z_thickness=0.17, + max_volume=350.0, + ), + ) + + +class RecordingFluorescenceBackend(FluorescenceBackend): + """Backend that records all calls for assertion.""" + + def __init__(self): + self.calls: List[tuple] = [] + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[FluorescenceResult]: + self.calls.append( + ("read_fluorescence", len(wells), excitation_wavelength, emission_wavelength, focal_height) + ) + data: List[List[Optional[float]]] = [ + [0.0] * plate.num_items_x for _ in range(plate.num_items_y) + ] + return [ + FluorescenceResult( + data=data, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + temperature=25.0, + timestamp=0.0, + ) + ] + + +class _TestDevice(Device): + def __init__(self, backend: FluorescenceBackend): + super().__init__(backend=backend) + self.fluorescence = FluorescenceCapability(backend=backend) + self._capabilities = [self.fluorescence] + + +class TestFluorescenceCapability(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = RecordingFluorescenceBackend() + self.device = _TestDevice(backend=self.backend) + await self.device.setup() + self.plate = _test_plate() + + async def asyncTearDown(self): + await self.device.stop() + + async def test_read_with_wells(self): + wells = [self.plate.get_well("A1"), self.plate.get_well("B2")] + results = await self.device.fluorescence.read( + plate=self.plate, + excitation_wavelength=485, + emission_wavelength=528, + focal_height=8.5, + wells=wells, + ) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].excitation_wavelength, 485) + self.assertEqual(results[0].emission_wavelength, 528) + self.assertEqual(len(self.backend.calls), 1) + _, n_wells, ex_wl, em_wl, fh = self.backend.calls[0] + self.assertEqual(n_wells, 2) + self.assertEqual(ex_wl, 485) + self.assertEqual(em_wl, 528) + self.assertAlmostEqual(fh, 8.5) + + async def test_read_all_wells(self): + results = await self.device.fluorescence.read( + plate=self.plate, + excitation_wavelength=485, + emission_wavelength=528, + focal_height=8.5, + ) + self.assertEqual(len(results), 1) + _, n_wells, *_ = self.backend.calls[0] + self.assertEqual(n_wells, 96) + + async def test_read_requires_setup(self): + backend = RecordingFluorescenceBackend() + cap = FluorescenceCapability(backend=backend) + with self.assertRaises(RuntimeError): + await cap.read( + plate=self.plate, + excitation_wavelength=485, + emission_wavelength=528, + focal_height=8.5, + ) + + +class TestFluorescenceChatterbox(unittest.IsolatedAsyncioTestCase): + async def test_chatterbox_read(self): + backend = FluorescenceChatterboxBackend() + device = _TestDevice(backend=backend) + await device.setup() + plate = _test_plate() + + wells = [plate.get_well("A1"), plate.get_well("C3")] + results = await device.fluorescence.read( + plate=plate, + excitation_wavelength=485, + emission_wavelength=528, + focal_height=8.5, + wells=wells, + ) + self.assertEqual(len(results), 1) + self.assertEqual(results[0].excitation_wavelength, 485) + self.assertEqual(results[0].emission_wavelength, 528) + self.assertEqual(results[0].data[0][0], 0.0) + self.assertIsNone(results[0].data[1][0]) + + await device.stop() + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/standard.py b/pylabrobot/capabilities/plate_reading/fluorescence/standard.py new file mode 100644 index 00000000000..e741c26effa --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/fluorescence/standard.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import dataclasses +from typing import List, Optional + + +@dataclasses.dataclass +class FluorescenceResult: + """Result of a fluorescence measurement. + + Attributes: + data: 2D array indexed [row][col]. ``None`` for unmeasured wells. + excitation_wavelength: Excitation wavelength in nm. + emission_wavelength: Emission wavelength in nm. + temperature: Temperature in degrees C, or ``None`` if not available. + timestamp: Unix timestamp of the measurement. + """ + + data: List[List[Optional[float]]] + excitation_wavelength: int + emission_wavelength: int + temperature: Optional[float] + timestamp: float diff --git a/pylabrobot/capabilities/plate_reading/luminescence/__init__.py b/pylabrobot/capabilities/plate_reading/luminescence/__init__.py new file mode 100644 index 00000000000..53243a8832b --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/luminescence/__init__.py @@ -0,0 +1,3 @@ +from .backend import LuminescenceBackend +from .luminescence import LuminescenceCapability +from .standard import LuminescenceResult diff --git a/pylabrobot/capabilities/plate_reading/luminescence/backend.py b/pylabrobot/capabilities/plate_reading/luminescence/backend.py new file mode 100644 index 00000000000..a836d643ee3 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/luminescence/backend.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from typing import List + +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.device import DeviceBackend +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + + +class LuminescenceBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for luminescence plate reading.""" + + @abstractmethod + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float + ) -> List[LuminescenceResult]: + """Read luminescence for the given wells. + + Args: + plate: The plate to read. + wells: Wells to measure. + focal_height: Focal height in mm. + + Returns: + A list of :class:`LuminescenceResult` (typically length 1). + """ diff --git a/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py b/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py new file mode 100644 index 00000000000..fbc089383f8 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py @@ -0,0 +1,33 @@ +import time +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.luminescence.backend import LuminescenceBackend +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.capabilities.plate_reading.utils import mask_wells +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + + +class LuminescenceChatterboxBackend(LuminescenceBackend): + """Mock luminescence backend for testing.""" + + def __init__(self): + self.dummy_luminescence: List[List[Optional[float]]] = [[0.0] * 12 for _ in range(8)] + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float + ) -> List[LuminescenceResult]: + data = mask_wells(self.dummy_luminescence, wells, plate) + return [ + LuminescenceResult( + data=data, + temperature=None, + timestamp=time.time(), + ) + ] diff --git a/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py b/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py new file mode 100644 index 00000000000..e400117fd66 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import logging +from typing import List, Optional + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +from .backend import LuminescenceBackend + +logger = logging.getLogger(__name__) + + +class LuminescenceCapability(Capability): + """Luminescence plate reading capability.""" + + def __init__(self, backend: LuminescenceBackend): + super().__init__(backend=backend) + self.backend: LuminescenceBackend = backend + + @need_capability_ready + async def read( + self, + plate: Plate, + focal_height: float, + wells: Optional[List[Well]] = None, + **backend_kwargs, + ) -> List[LuminescenceResult]: + """Read luminescence from a plate. + + Args: + plate: The plate to read. + focal_height: Focal height in mm. + wells: Wells to measure. Defaults to all wells in the plate. + **backend_kwargs: Additional keyword arguments passed to the backend. + + Returns: + A list of :class:`LuminescenceResult` (typically length 1). + """ + if wells is None: + wells = plate.get_all_items() + return await self.backend.read_luminescence( + plate=plate, wells=wells, focal_height=focal_height, **backend_kwargs + ) diff --git a/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py new file mode 100644 index 00000000000..5270564a792 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py @@ -0,0 +1,126 @@ +"""Tests for LuminescenceCapability.""" + +import unittest +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.luminescence.backend import LuminescenceBackend +from pylabrobot.capabilities.plate_reading.luminescence.chatterbox import ( + LuminescenceChatterboxBackend, +) +from pylabrobot.capabilities.plate_reading.luminescence.luminescence import LuminescenceCapability +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.device import Device +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.utils import create_ordered_items_2d +from pylabrobot.resources.well import Well, WellBottomType + + +def _test_plate() -> Plate: + return Plate( + name="test_plate", + size_x=127.6, + size_y=85.75, + size_z=13.83, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=8, + dx=10.9, + dy=7.96, + dz=1.5, + item_dx=9.0, + item_dy=9.0, + size_x=6.8, + size_y=6.8, + size_z=10.67, + bottom_type=WellBottomType.FLAT, + material_z_thickness=0.17, + max_volume=350.0, + ), + ) + + +class RecordingLuminescenceBackend(LuminescenceBackend): + """Backend that records all calls for assertion.""" + + def __init__(self): + self.calls: List[tuple] = [] + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + ) -> List[LuminescenceResult]: + self.calls.append(("read_luminescence", len(wells), focal_height)) + data: List[List[Optional[float]]] = [ + [0.0] * plate.num_items_x for _ in range(plate.num_items_y) + ] + return [LuminescenceResult(data=data, temperature=25.0, timestamp=0.0)] + + +class _TestDevice(Device): + def __init__(self, backend: LuminescenceBackend): + super().__init__(backend=backend) + self.luminescence = LuminescenceCapability(backend=backend) + self._capabilities = [self.luminescence] + + +class TestLuminescenceCapability(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = RecordingLuminescenceBackend() + self.device = _TestDevice(backend=self.backend) + await self.device.setup() + self.plate = _test_plate() + + async def asyncTearDown(self): + await self.device.stop() + + async def test_read_with_wells(self): + wells = [self.plate.get_well("A1"), self.plate.get_well("B2")] + results = await self.device.luminescence.read(plate=self.plate, focal_height=13.0, wells=wells) + self.assertEqual(len(results), 1) + self.assertEqual(len(self.backend.calls), 1) + _, n_wells, fh = self.backend.calls[0] + self.assertEqual(n_wells, 2) + self.assertAlmostEqual(fh, 13.0) + + async def test_read_all_wells(self): + results = await self.device.luminescence.read(plate=self.plate, focal_height=13.0) + self.assertEqual(len(results), 1) + _, n_wells, _ = self.backend.calls[0] + self.assertEqual(n_wells, 96) + + async def test_read_requires_setup(self): + backend = RecordingLuminescenceBackend() + cap = LuminescenceCapability(backend=backend) + with self.assertRaises(RuntimeError): + await cap.read(plate=self.plate, focal_height=13.0) + + +class TestLuminescenceChatterbox(unittest.IsolatedAsyncioTestCase): + async def test_chatterbox_read(self): + backend = LuminescenceChatterboxBackend() + device = _TestDevice(backend=backend) + await device.setup() + plate = _test_plate() + + wells = [plate.get_well("A1"), plate.get_well("C3")] + results = await device.luminescence.read(plate=plate, focal_height=13.0, wells=wells) + self.assertEqual(len(results), 1) + # A1 = row 0, col 0 => measured + self.assertEqual(results[0].data[0][0], 0.0) + # B1 = row 1, col 0 => not measured + self.assertIsNone(results[0].data[1][0]) + + await device.stop() + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/capabilities/plate_reading/luminescence/standard.py b/pylabrobot/capabilities/plate_reading/luminescence/standard.py new file mode 100644 index 00000000000..36cf8376d08 --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/luminescence/standard.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import dataclasses +from typing import List, Optional + + +@dataclasses.dataclass +class LuminescenceResult: + """Result of a luminescence measurement. + + Attributes: + data: 2D array indexed [row][col]. ``None`` for unmeasured wells. + temperature: Temperature in °C, or ``None`` if not available. + timestamp: Unix timestamp of the measurement. + """ + + data: List[List[Optional[float]]] + temperature: Optional[float] + timestamp: float diff --git a/pylabrobot/capabilities/plate_reading/utils.py b/pylabrobot/capabilities/plate_reading/utils.py new file mode 100644 index 00000000000..c20bb2a363a --- /dev/null +++ b/pylabrobot/capabilities/plate_reading/utils.py @@ -0,0 +1,18 @@ +from typing import List, Optional + +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + + +def mask_wells( + result: List[List[Optional[float]]], wells: List[Well], plate: Plate +) -> List[List[Optional[float]]]: + """Return a copy of *result* with only the requested wells; others become ``None``.""" + masked: List[List[Optional[float]]] = [ + [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) + ] + for well in wells: + r, c = well.get_row(), well.get_column() + if r < plate.num_items_y and c < plate.num_items_x: + masked[r][c] = result[r][c] + return masked diff --git a/pylabrobot/capabilities/sealing/__init__.py b/pylabrobot/capabilities/sealing/__init__.py new file mode 100644 index 00000000000..368729a121d --- /dev/null +++ b/pylabrobot/capabilities/sealing/__init__.py @@ -0,0 +1,2 @@ +from .backend import SealerBackend +from .sealing import SealingCapability diff --git a/pylabrobot/capabilities/sealing/backend.py b/pylabrobot/capabilities/sealing/backend.py new file mode 100644 index 00000000000..4152f97e8f2 --- /dev/null +++ b/pylabrobot/capabilities/sealing/backend.py @@ -0,0 +1,19 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.device import DeviceBackend + + +class SealerBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for sealing devices.""" + + @abstractmethod + async def seal(self, temperature: int, duration: float): + """Perform a seal operation at the given temperature and duration.""" + + @abstractmethod + async def open(self): + """Open the sealer shuttle.""" + + @abstractmethod + async def close(self): + """Close the sealer shuttle.""" diff --git a/pylabrobot/capabilities/sealing/sealing.py b/pylabrobot/capabilities/sealing/sealing.py new file mode 100644 index 00000000000..ac8833a49fc --- /dev/null +++ b/pylabrobot/capabilities/sealing/sealing.py @@ -0,0 +1,23 @@ +from pylabrobot.capabilities.capability import Capability + +from .backend import SealerBackend + + +class SealingCapability(Capability): + """Sealing capability.""" + + def __init__(self, backend: SealerBackend): + super().__init__(backend=backend) + self.backend: SealerBackend = backend + + async def seal(self, temperature: int, duration: float): + await self.backend.seal(temperature=temperature, duration=duration) + + async def open(self): + await self.backend.open() + + async def close(self): + await self.backend.close() + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/capabilities/shaking/__init__.py b/pylabrobot/capabilities/shaking/__init__.py new file mode 100644 index 00000000000..55decd44c19 --- /dev/null +++ b/pylabrobot/capabilities/shaking/__init__.py @@ -0,0 +1,2 @@ +from .backend import ShakerBackend +from .shaking import ShakingCapability diff --git a/pylabrobot/capabilities/shaking/backend.py b/pylabrobot/capabilities/shaking/backend.py new file mode 100644 index 00000000000..e23bcad26f3 --- /dev/null +++ b/pylabrobot/capabilities/shaking/backend.py @@ -0,0 +1,28 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.device import DeviceBackend + + +class ShakerBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for shaking devices.""" + + @abstractmethod + async def start_shaking(self, speed: float): + """Start shaking at the given speed in RPM.""" + + @abstractmethod + async def stop_shaking(self): + """Stop shaking.""" + + @property + @abstractmethod + def supports_locking(self) -> bool: + """Whether this backend supports locking the plate.""" + + @abstractmethod + async def lock_plate(self): + """Lock the plate.""" + + @abstractmethod + async def unlock_plate(self): + """Unlock the plate.""" diff --git a/pylabrobot/capabilities/shaking/shaking.py b/pylabrobot/capabilities/shaking/shaking.py new file mode 100644 index 00000000000..204953dfa30 --- /dev/null +++ b/pylabrobot/capabilities/shaking/shaking.py @@ -0,0 +1,47 @@ +import asyncio +from typing import Optional + +from pylabrobot.capabilities.capability import Capability + +from .backend import ShakerBackend + + +class ShakingCapability(Capability): + """Shaking capability.""" + + def __init__(self, backend: ShakerBackend): + super().__init__(backend=backend) + self.backend: ShakerBackend = backend + + async def shake(self, speed: float, duration: Optional[float] = None): + """Shake at the given speed. + + Args: + speed: Speed in RPM. + duration: Duration in seconds. If None, shake indefinitely (return immediately). + """ + if self.backend.supports_locking: + await self.backend.lock_plate() + await self.backend.start_shaking(speed=speed) + + if duration is None: + return + + await asyncio.sleep(duration) + await self.backend.stop_shaking() + if self.backend.supports_locking: + await self.backend.unlock_plate() + + async def stop_shaking(self): + await self.backend.stop_shaking() + + async def lock_plate(self): + await self.backend.lock_plate() + + async def unlock_plate(self): + await self.backend.unlock_plate() + + async def _on_stop(self): + if self._setup_finished: + await self.backend.stop_shaking() + await super()._on_stop() diff --git a/pylabrobot/capabilities/temperature_controlling/__init__.py b/pylabrobot/capabilities/temperature_controlling/__init__.py new file mode 100644 index 00000000000..705339cac3b --- /dev/null +++ b/pylabrobot/capabilities/temperature_controlling/__init__.py @@ -0,0 +1,2 @@ +from .backend import TemperatureControllerBackend +from .temperature_controller import TemperatureControlCapability diff --git a/pylabrobot/capabilities/temperature_controlling/backend.py b/pylabrobot/capabilities/temperature_controlling/backend.py new file mode 100644 index 00000000000..77fd4c7f693 --- /dev/null +++ b/pylabrobot/capabilities/temperature_controlling/backend.py @@ -0,0 +1,24 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.device import DeviceBackend + + +class TemperatureControllerBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for temperature controllers.""" + + @property + @abstractmethod + def supports_active_cooling(self) -> bool: + """Whether this backend can actively cool below the current temperature.""" + + @abstractmethod + async def set_temperature(self, temperature: float): + """Set the temperature of the temperature controller in Celsius.""" + + @abstractmethod + async def get_current_temperature(self) -> float: + """Get the current temperature of the temperature controller in Celsius""" + + @abstractmethod + async def deactivate(self): + """Deactivate the temperature controller.""" diff --git a/pylabrobot/temperature_controlling/temperature_controller.py b/pylabrobot/capabilities/temperature_controlling/temperature_controller.py similarity index 71% rename from pylabrobot/temperature_controlling/temperature_controller.py rename to pylabrobot/capabilities/temperature_controlling/temperature_controller.py index 3b58305c7dc..afd6f547c5b 100644 --- a/pylabrobot/temperature_controlling/temperature_controller.py +++ b/pylabrobot/capabilities/temperature_controlling/temperature_controller.py @@ -2,37 +2,16 @@ import time from typing import Optional -from pylabrobot.machines.machine import Machine -from pylabrobot.resources import Coordinate, ResourceHolder +from pylabrobot.capabilities.capability import Capability from .backend import TemperatureControllerBackend -class TemperatureController(ResourceHolder, Machine): - """Temperature controller, for heating or for cooling.""" - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - backend: TemperatureControllerBackend, - child_location: Coordinate, - category: str = "temperature_controller", - model: Optional[str] = None, - ): - ResourceHolder.__init__( - self, - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - child_location=child_location, - category=category, - model=model, - ) - Machine.__init__(self, backend=backend) +class TemperatureControlCapability(Capability): + """Temperature control capability, for heating or cooling.""" + + def __init__(self, backend: TemperatureControllerBackend): + super().__init__(backend=backend) self.backend: TemperatureControllerBackend = backend # fix type self.target_temperature: Optional[float] = None @@ -93,13 +72,8 @@ async def deactivate(self): self.target_temperature = None return await self.backend.deactivate() - async def stop(self): - """Stop the temperature controller and close the backend connection.""" - await self.deactivate() - await super().stop() - - def serialize(self) -> dict: - return { - **Machine.serialize(self), - **ResourceHolder.serialize(self), - } + async def _on_stop(self): + """Called by the parent Machine before backend.stop().""" + if self._setup_finished: + await self.deactivate() + await super()._on_stop() diff --git a/pylabrobot/capabilities/tilting/__init__.py b/pylabrobot/capabilities/tilting/__init__.py new file mode 100644 index 00000000000..080340cde41 --- /dev/null +++ b/pylabrobot/capabilities/tilting/__init__.py @@ -0,0 +1,2 @@ +from .backend import TilterBackend, TiltModuleError +from .tilting import TiltingCapability diff --git a/pylabrobot/capabilities/tilting/backend.py b/pylabrobot/capabilities/tilting/backend.py new file mode 100644 index 00000000000..57eed4824e5 --- /dev/null +++ b/pylabrobot/capabilities/tilting/backend.py @@ -0,0 +1,19 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.device import DeviceBackend + + +class TiltModuleError(Exception): + """Error raised by a tilt module backend.""" + + +class TilterBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for tilting devices.""" + + @abstractmethod + async def set_angle(self, angle: float): + """Set the tilt angle. + + Args: + angle: The angle in degrees. 0 is horizontal. + """ diff --git a/pylabrobot/capabilities/tilting/tilting.py b/pylabrobot/capabilities/tilting/tilting.py new file mode 100644 index 00000000000..41d13dbc256 --- /dev/null +++ b/pylabrobot/capabilities/tilting/tilting.py @@ -0,0 +1,36 @@ +from pylabrobot.capabilities.capability import Capability + +from .backend import TilterBackend + + +class TiltingCapability(Capability): + """Tilting capability.""" + + def __init__(self, backend: TilterBackend): + super().__init__(backend=backend) + self.backend: TilterBackend = backend + self._absolute_angle: float = 0 + + @property + def absolute_angle(self) -> float: + return self._absolute_angle + + async def set_angle(self, absolute_angle: float): + """Set the tilt angle. + + Args: + absolute_angle: The absolute angle in degrees. 0 is horizontal. + """ + await self.backend.set_angle(angle=absolute_angle) + self._absolute_angle = absolute_angle + + async def tilt(self, relative_angle: float): + """Tilt by a relative angle from the current position. + + Args: + relative_angle: The angle to tilt by, in degrees. + """ + await self.set_angle(self._absolute_angle + relative_angle) + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/capabilities/weighing/__init__.py b/pylabrobot/capabilities/weighing/__init__.py new file mode 100644 index 00000000000..2e7a3ab77f6 --- /dev/null +++ b/pylabrobot/capabilities/weighing/__init__.py @@ -0,0 +1,2 @@ +from .backend import ScaleBackend +from .weighing import WeighingCapability diff --git a/pylabrobot/capabilities/weighing/backend.py b/pylabrobot/capabilities/weighing/backend.py new file mode 100644 index 00000000000..3b541e2e22d --- /dev/null +++ b/pylabrobot/capabilities/weighing/backend.py @@ -0,0 +1,19 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.device import DeviceBackend + + +class ScaleBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for scales.""" + + @abstractmethod + async def zero(self): + """Zero the scale.""" + + @abstractmethod + async def tare(self): + """Tare the scale.""" + + @abstractmethod + async def read_weight(self) -> float: + """Read the weight in grams.""" diff --git a/pylabrobot/capabilities/weighing/weighing.py b/pylabrobot/capabilities/weighing/weighing.py new file mode 100644 index 00000000000..cd26289fbc1 --- /dev/null +++ b/pylabrobot/capabilities/weighing/weighing.py @@ -0,0 +1,23 @@ +from pylabrobot.capabilities.capability import Capability + +from .backend import ScaleBackend + + +class WeighingCapability(Capability): + """Weighing capability.""" + + def __init__(self, backend: ScaleBackend): + super().__init__(backend=backend) + self.backend: ScaleBackend = backend + + async def zero(self): + await self.backend.zero() + + async def tare(self): + await self.backend.tare() + + async def read_weight(self) -> float: + return await self.backend.read_weight() + + async def _on_stop(self): + await super()._on_stop() diff --git a/pylabrobot/device.py b/pylabrobot/device.py new file mode 100644 index 00000000000..aa696939d74 --- /dev/null +++ b/pylabrobot/device.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import functools +import inspect +import sys +import weakref +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, TypeVar + +from pylabrobot.serializer import SerializableMixin +from pylabrobot.utils.object_parsing import find_subclass + +if TYPE_CHECKING: + from pylabrobot.capabilities.capability import Capability + +if sys.version_info < (3, 10): + from typing_extensions import ParamSpec +else: + from typing import ParamSpec + +_P = ParamSpec("_P") +_R = TypeVar("_R", bound=Awaitable[Any]) + + +class DeviceBackend(SerializableMixin, ABC): + """Abstract class for device backends.""" + + _instances: weakref.WeakSet["DeviceBackend"] = weakref.WeakSet() + + def __init__(self): + self._instances.add(self) + + @abstractmethod + async def setup(self): + pass + + @abstractmethod + async def stop(self): + pass + + def serialize(self) -> dict: + return {"type": self.__class__.__name__} + + @classmethod + def deserialize(cls, data: dict): + class_name = data.pop("type") + subclass = find_subclass(class_name, cls=cls) + if subclass is None: + raise ValueError(f'Could not find subclass with name "{class_name}"') + if inspect.isabstract(subclass): + raise ValueError(f'Subclass with name "{class_name}" is abstract') + assert issubclass(subclass, cls) + return subclass(**data) + + @classmethod + def get_all_instances(cls): + return cls._instances + + +def need_setup_finished(func: Callable[_P, _R]) -> Callable[_P, _R]: + """Decorator for methods that require the device to be set up. + + Checked by verifying `self.setup_finished` is `True`. + + Raises: + RuntimeError: If the device is not set up. + """ + + @functools.wraps(func) + async def wrapper(*args, **kwargs): + assert isinstance(args[0], Device), "The first argument must be a Device." + self = args[0] + + if not self.setup_finished: + raise RuntimeError("The setup has not finished. See `setup`.") + return await func(*args, **kwargs) + + return wrapper + + +class Device(SerializableMixin, ABC): + """Abstract base class for device frontends.""" + + def __init__(self, backend: DeviceBackend): + self._backend = backend + self._setup_finished = False + self._capabilities: List[Capability] = [] + + @property + def setup_finished(self) -> bool: + return self._setup_finished + + def serialize(self) -> dict: + return {"backend": self._backend.serialize()} + + @classmethod + def deserialize(cls, data: dict): + data_copy = data.copy() + backend_data = data_copy.pop("backend") + backend = DeviceBackend.deserialize(backend_data) + data_copy["backend"] = backend + return cls(**data_copy) + + async def setup(self, **backend_kwargs): + await self._backend.setup(**backend_kwargs) + for cap in self._capabilities: + await cap._on_setup() + self._setup_finished = True + + @need_setup_finished + async def stop(self): + for cap in reversed(self._capabilities): + await cap._on_stop() + await self._backend.stop() + self._setup_finished = False + + async def __aenter__(self): + await self.setup() + return self + + async def __aexit__(self, exc_type, exc_value, traceback): + await self.stop() diff --git a/pylabrobot/liquid_handling/liquid_classes/__init__.py b/pylabrobot/hamilton/__init__.py similarity index 100% rename from pylabrobot/liquid_handling/liquid_classes/__init__.py rename to pylabrobot/hamilton/__init__.py diff --git a/pylabrobot/hamilton/heater_shaker/__init__.py b/pylabrobot/hamilton/heater_shaker/__init__.py new file mode 100644 index 00000000000..7a95a1d7037 --- /dev/null +++ b/pylabrobot/hamilton/heater_shaker/__init__.py @@ -0,0 +1,3 @@ +from .backend import HamiltonHeaterShakerBackend +from .box import HamiltonHeaterShakerBox, HamiltonHeaterShakerInterface +from .heater_shaker import HamiltonHeaterShaker diff --git a/pylabrobot/heating_shaking/hamilton_backend.py b/pylabrobot/hamilton/heater_shaker/backend.py similarity index 54% rename from pylabrobot/heating_shaking/hamilton_backend.py rename to pylabrobot/hamilton/heater_shaker/backend.py index f302502115a..7008c7fc5e3 100644 --- a/pylabrobot/heating_shaking/hamilton_backend.py +++ b/pylabrobot/hamilton/heater_shaker/backend.py @@ -1,11 +1,13 @@ -import abc import time import warnings from enum import Enum from typing import Dict, Literal, Optional -from pylabrobot.heating_shaking.backend import HeaterShakerBackend -from pylabrobot.io.usb import USB +from pylabrobot.capabilities.shaking import ShakerBackend +from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend +from pylabrobot.device import DeviceBackend + +from .box import HamiltonHeaterShakerInterface class PlateLockPosition(Enum): @@ -13,90 +15,36 @@ class PlateLockPosition(Enum): UNLOCKED = 0 -class HamiltonHeaterShakerInterface(abc.ABC): - """Either a control box or a STAR: the api is the same""" - - @abc.abstractmethod - async def send_hhs_command(self, index: int, command: str, **kwargs) -> str: - pass - - -class HamiltonHeaterShakerBox(HamiltonHeaterShakerInterface): - def __init__( - self, - id_vendor: int = 0x8AF, - id_product: int = 0x8002, - device_address: Optional[int] = None, - serial_number: Optional[str] = None, - ): - self.io = USB( - human_readable_device_name="Hamilton Heater Shaker Box", - id_vendor=id_vendor, - id_product=id_product, - device_address=device_address, - serial_number=serial_number, - ) - self._id = 0 - - def _generate_id(self) -> int: - """continuously generate unique ids 0 <= x < 10000.""" - self._id += 1 - return self._id % 10000 - - async def setup(self): - """ - If io.setup() fails, ensure that libusb drivers were installed for the HHS as per docs. - """ - await self.io.setup() - - async def stop(self): - await self.io.stop() - - async def send_hhs_command(self, index: int, command: str, **kwargs) -> str: - args = "".join([f"{key}{value}" for key, value in kwargs.items()]) - id_ = str(self._generate_id()).zfill(4) - await self.io.write(f"T{index}{command}id{id_}{args}".encode()) - return (await self.io.read()).decode("utf-8") - - -class HamiltonHeaterShakerBackend(HeaterShakerBackend): - """Backend for Hamilton Heater Shaker devices connected through an Heater Shaker Box""" - - @property - def supports_active_cooling(self) -> bool: - return False +class HamiltonHeaterShakerBackend(TemperatureControllerBackend, ShakerBackend): + """Backend for Hamilton Heater Shaker devices.""" def __init__(self, index: int, interface: HamiltonHeaterShakerInterface) -> None: - """ - Multiple Hamilton Heater Shakers can be connected to the same Heat Shaker Box. Each has A - unique 'shaker index' - """ assert index >= 0, "Shaker index must be non-negative" self.index = index - - super().__init__() self.interface = interface + @property + def supports_active_cooling(self) -> bool: + return False + async def setup(self): - """ - If io.setup() fails, ensure that libusb drivers were installed for the HHS as per docs. - """ + await DeviceBackend.setup(self) await self._initialize_lock() await self._initialize_shaker_drive() async def stop(self): - pass + await DeviceBackend.stop(self) def serialize(self) -> dict: warnings.warn("The interface is not serialized.") - - heater_shaker_serialized = HeaterShakerBackend.serialize(self) return { - **heater_shaker_serialized, + **DeviceBackend.serialize(self), "index": self.index, - "interface": None, # TODO: implement serialization + "interface": None, } + # -- shaking -- + async def start_shaking( self, speed: float = 800, @@ -104,14 +52,6 @@ async def start_shaking( acceleration: int = 1_000, timeout: Optional[float] = 30, ): - """ - if the plate is not locked, it will be locked. - - speed: steps per second - direction: 0 for positive, 1 for negative - acceleration: increments per second - """ - await self.lock_plate() int_speed = int(speed) @@ -127,33 +67,13 @@ async def start_shaking( if timeout is not None and time.time() - now > timeout: raise TimeoutError("Failed to start shaking within timeout") - async def shake( - self, - speed: float = 800, - direction: Literal[0, 1] = 0, - acceleration: int = 1_000, - timeout: Optional[float] = 30, - ): - warnings.warn( - "HamiltonHeaterShakerBackend.shake() is deprecated and will be removed in a future release. " - "Use start_shaking() instead.", - DeprecationWarning, - stacklevel=2, - ) - await self.start_shaking( - speed=speed, - direction=direction, - acceleration=acceleration, - timeout=timeout, - ) - async def stop_shaking(self): await self._stop_shaking() await self._wait_for_stop() async def get_is_shaking(self) -> bool: response = await self.interface.send_hhs_command(index=self.index, command="RD") - return response.endswith("1") # type: ignore[no-any-return] # what + return response.endswith("1") async def _move_plate_lock(self, position: PlateLockPosition): return await self.interface.send_hhs_command(index=self.index, command="LP", lp=position.value) @@ -169,15 +89,12 @@ async def unlock_plate(self): await self._move_plate_lock(PlateLockPosition.UNLOCKED) async def _initialize_lock(self): - """Firmware command initialize lock.""" return await self.interface.send_hhs_command(index=self.index, command="LI") async def _initialize_shaker_drive(self): - """Initialize the shaker drive, homing to absolute position 0""" return await self.interface.send_hhs_command(index=self.index, command="SI") async def _start_shaking(self, direction: int, speed: int, acceleration: int): - """Firmware command for starting shaking.""" speed_str = str(speed).zfill(4) acceleration_str = str(acceleration).zfill(5) return await self.interface.send_hhs_command( @@ -185,21 +102,19 @@ async def _start_shaking(self, direction: int, speed: int, acceleration: int): ) async def _stop_shaking(self): - """Firmware command for stopping shaking.""" return await self.interface.send_hhs_command(index=self.index, command="SC") async def _wait_for_stop(self): - """Firmware command for waiting for shaking to stop.""" return await self.interface.send_hhs_command(index=self.index, command="SW") + # -- temperature -- + async def set_temperature(self, temperature: float): - """set temperature in Celsius""" assert 0 < temperature <= 105 temp_str = f"{round(10 * temperature):04d}" return await self.interface.send_hhs_command(index=self.index, command="TA", ta=temp_str) async def _get_current_temperature(self) -> Dict[str, float]: - """get temperature in Celsius""" response = await self.interface.send_hhs_command(index=self.index, command="RT") response = response.split("rt")[1] middle_temp = float(str(response).split(" ")[0].strip("+")) / 10 @@ -207,15 +122,12 @@ async def _get_current_temperature(self) -> Dict[str, float]: return {"middle": middle_temp, "edge": edge_temp} async def get_current_temperature(self) -> float: - """get temperature in Celsius""" response = await self._get_current_temperature() return response["middle"] async def get_edge_temperature(self) -> float: - """get temperature in Celsius""" response = await self._get_current_temperature() return response["edge"] async def deactivate(self): - """turn off heating""" return await self.interface.send_hhs_command(index=self.index, command="TO") diff --git a/pylabrobot/hamilton/heater_shaker/box.py b/pylabrobot/hamilton/heater_shaker/box.py new file mode 100644 index 00000000000..b24b8a01907 --- /dev/null +++ b/pylabrobot/hamilton/heater_shaker/box.py @@ -0,0 +1,52 @@ +import abc +from typing import Optional + +from pylabrobot.io.usb import USB + + +class HamiltonHeaterShakerInterface(abc.ABC): + """Interface for communicating with Hamilton Heater Shakers. + + Either a control box or a STAR: the API is the same. + """ + + @abc.abstractmethod + async def send_hhs_command(self, index: int, command: str, **kwargs) -> str: + pass + + +class HamiltonHeaterShakerBox(HamiltonHeaterShakerInterface): + """USB control box for Hamilton Heater Shaker devices.""" + + def __init__( + self, + id_vendor: int = 0x8AF, + id_product: int = 0x8002, + device_address: Optional[int] = None, + serial_number: Optional[str] = None, + ): + self.io = USB( + id_vendor=id_vendor, + id_product=id_product, + human_readable_device_name="Hamilton Heater Shaker Box", + device_address=device_address, + serial_number=serial_number, + ) + self._id = 0 + + def _generate_id(self) -> int: + """Continuously generate unique ids 0 <= x < 10000.""" + self._id += 1 + return self._id % 10000 + + async def setup(self): + await self.io.setup() + + async def stop(self): + await self.io.stop() + + async def send_hhs_command(self, index: int, command: str, **kwargs) -> str: + args = "".join([f"{key}{value}" for key, value in kwargs.items()]) + id_ = str(self._generate_id()).zfill(4) + await self.io.write(f"T{index}{command}id{id_}{args}".encode()) + return (await self.io.read()).decode("utf-8") diff --git a/pylabrobot/hamilton/heater_shaker/heater_shaker.py b/pylabrobot/hamilton/heater_shaker/heater_shaker.py new file mode 100644 index 00000000000..fda19b8f300 --- /dev/null +++ b/pylabrobot/hamilton/heater_shaker/heater_shaker.py @@ -0,0 +1,49 @@ +from typing import Optional + +from pylabrobot.capabilities.shaking import ShakingCapability +from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate +from pylabrobot.resources.carrier import PlateHolder + +from .backend import HamiltonHeaterShakerBackend + + +class HamiltonHeaterShaker(PlateHolder, Device): + """Hamilton Heater Shaker: combined temperature control and shaking.""" + + def __init__( + self, + name: str, + backend: HamiltonHeaterShakerBackend, + size_x: float = 146.2, + size_y: float = 103.6, + size_z: float = 74.11, + child_location: Coordinate = Coordinate(x=10, y=13, z=74.24), + pedestal_size_z: float = 0, + category: str = "heating_shaking", + model: Optional[str] = None, + ): + raise NotImplementedError("HamiltonHeaterShaker resource definition is not verified.") + PlateHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + child_location=child_location, + pedestal_size_z=pedestal_size_z, + category=category, + model=model, + ) + Device.__init__(self, backend=backend) + self._backend: HamiltonHeaterShakerBackend = backend + self.tc = TemperatureControlCapability(backend=backend) + self.shaker = ShakingCapability(backend=backend) + self._capabilities = [self.tc, self.shaker] + + def serialize(self) -> dict: + return { + **Device.serialize(self), + **PlateHolder.serialize(self), + } diff --git a/pylabrobot/hamilton/only_fans/__init__.py b/pylabrobot/hamilton/only_fans/__init__.py new file mode 100644 index 00000000000..ea293e10da9 --- /dev/null +++ b/pylabrobot/hamilton/only_fans/__init__.py @@ -0,0 +1,2 @@ +from .backend import HamiltonHepaFanBackend, HamiltonHepaFanChatterboxBackend +from .hepa_fan import HamiltonHepaFan diff --git a/pylabrobot/hamilton/only_fans/backend.py b/pylabrobot/hamilton/only_fans/backend.py new file mode 100644 index 00000000000..54012ce34de --- /dev/null +++ b/pylabrobot/hamilton/only_fans/backend.py @@ -0,0 +1,169 @@ +import asyncio +from typing import Optional + +from pylabrobot.capabilities.fan_control import FanBackend +from pylabrobot.io.ftdi import FTDI + + +class HamiltonHepaFanBackend(FanBackend): + """Backend for the Hamilton HEPA fan attachment.""" + + def __init__(self, device_id: Optional[str] = None): + self.io = FTDI( + human_readable_device_name="Hamilton HEPA Fan", + device_id=device_id, + vid=0x0856, + pid=0xAC11, + ) + + async def setup(self): + await self.io.setup() + await self.io.set_baudrate(9600) + await self.io.set_line_property(8, 0, 0) # 8N1 + await self.io.set_latency_timer(16) + await self.io.set_flowctrl(512) + await self.io.set_dtr(True) + await self.io.set_rts(True) + + await self._send(b"\x55\xc1\x01\x02\x23\x4b") + await self._send(b"\x55\xc1\x01\x08\x08\x6a") + await self._send(b"\x55\xc1\x01\x09\x6a\x09") + await self._send(b"\x55\xc1\x01\x0a\x2f\x4f") + await self._send(b"\x15\x61\x01\x8a") + + async def stop(self): + await self.io.stop() + + _SPEED_TABLE = [ + "55c10111007b", + "55c101110279", + "55c10111057e", + "55c10111077c", + "55c101110a71", + "55c101110c77", + "55c101110f74", + "55c10111116a", + "55c10111146f", + "55c10111166d", + "55c101111962", + "55c101111c67", + "55c101111e65", + "55c10111215a", + "55c101112358", + "55c10111265d", + "55c101112853", + "55c101112b50", + "55c101112d56", + "55c10111304b", + "55c101113249", + "55c10111354e", + "55c101113843", + "55c101113a41", + "55c101113d46", + "55c101113f44", + "55c101114239", + "55c10111443f", + "55c10111473c", + "55c101114932", + "55c101114c37", + "55c101114f34", + "55c10111512a", + "55c10111542f", + "55c10111562d", + "55c101115922", + "55c101115b20", + "55c101115e25", + "55c10111601b", + "55c101116318", + "55c10111651e", + "55c101116813", + "55c101116b10", + "55c101116d16", + "55c10111700b", + "55c101117209", + "55c10111750e", + "55c10111770c", + "55c101117a01", + "55c101117c07", + "55c101117f04", + "55c1011182f9", + "55c1011184ff", + "55c1011187fc", + "55c1011189f2", + "55c101118cf7", + "55c101118ef5", + "55c1011191ea", + "55c1011193e8", + "55c1011196ed", + "55c1011198e3", + "55c101119be0", + "55c101119ee5", + "55c10111a0db", + "55c10111a3d8", + "55c10111a5de", + "55c10111a8d3", + "55c10111aad1", + "55c10111add6", + "55c10111afd4", + "55c10111b2c9", + "55c10111b5ce", + "55c10111b7cc", + "55c10111bac1", + "55c10111bcc7", + "55c10111bfc4", + "55c10111c1ba", + "55c10111c4bf", + "55c10111c6bd", + "55c10111c9b2", + "55c10111cbb0", + "55c10111ceb5", + "55c10111d1aa", + "55c10111d3a8", + "55c10111d6ad", + "55c10111d8a3", + "55c10111dba0", + "55c10111dda6", + "55c10111e09b", + "55c10111e299", + "55c10111e59e", + "55c10111e893", + "55c10111ea91", + "55c10111ed96", + "55c10111ef94", + "55c10111f289", + "55c10111f48f", + "55c10111f78c", + "55c10111f982", + "55c10111fc87", + "55c10111fe85", + ] + + async def turn_on(self, intensity: int) -> None: + if int(intensity) != intensity or not 0 <= intensity <= 100: + raise ValueError("Intensity must be an integer between 0 and 100") + await self._send(b"\x35\x41\x01\xff\x75") # turn on + await self._send(bytes.fromhex(self._SPEED_TABLE[intensity])) # set speed + + async def turn_off(self) -> None: + await self._send(b"\x55\xc1\x01\x11\x00\x7b") + + async def _send(self, command: bytes): + await self.io.write(command) + await asyncio.sleep(0.1) + await self.io.read(64) + + +class HamiltonHepaFanChatterboxBackend(FanBackend): + """Chatterbox backend for device-free testing.""" + + async def setup(self) -> None: + pass + + async def turn_on(self, intensity: int) -> None: + pass + + async def turn_off(self) -> None: + pass + + async def stop(self) -> None: + pass diff --git a/pylabrobot/hamilton/only_fans/hepa_fan.py b/pylabrobot/hamilton/only_fans/hepa_fan.py new file mode 100644 index 00000000000..42bd521a387 --- /dev/null +++ b/pylabrobot/hamilton/only_fans/hepa_fan.py @@ -0,0 +1,17 @@ +from typing import Optional + +from pylabrobot.capabilities.fan_control import FanControlCapability +from pylabrobot.device import Device + +from .backend import HamiltonHepaFanBackend + + +class HamiltonHepaFan(Device): + """Hamilton HEPA fan attachment.""" + + def __init__(self, name: str, device_id: Optional[str] = None): + backend = HamiltonHepaFanBackend(device_id=device_id) + super().__init__(backend=backend) + self._backend: HamiltonHepaFanBackend = backend + self.fan = FanControlCapability(backend=backend) + self._capabilities = [self.fan] diff --git a/pylabrobot/hamilton/tilt_module/__init__.py b/pylabrobot/hamilton/tilt_module/__init__.py new file mode 100644 index 00000000000..6d271987219 --- /dev/null +++ b/pylabrobot/hamilton/tilt_module/__init__.py @@ -0,0 +1,2 @@ +from .backend import HamiltonTiltModuleBackend, HamiltonTiltModuleChatterboxBackend +from .tilt_module import HamiltonTiltModule diff --git a/pylabrobot/tilting/hamilton_backend.py b/pylabrobot/hamilton/tilt_module/backend.py similarity index 81% rename from pylabrobot/tilting/hamilton_backend.py rename to pylabrobot/hamilton/tilt_module/backend.py index 34ea0337d8b..c598068d0f7 100644 --- a/pylabrobot/tilting/hamilton_backend.py +++ b/pylabrobot/hamilton/tilt_module/backend.py @@ -9,11 +9,8 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e +from pylabrobot.capabilities.tilting.backend import TilterBackend, TiltModuleError from pylabrobot.io.serial import Serial -from pylabrobot.tilting.tilter_backend import ( - TilterBackend, - TiltModuleError, -) class HamiltonTiltModuleBackend(TilterBackend): @@ -27,7 +24,8 @@ def __init__( ): if not HAS_SERIAL: raise RuntimeError( - f"pyserial is required for the Hamilton tilt module backend. Import error: {_SERIAL_IMPORT_ERROR}" + f"pyserial is required for the Hamilton tilt module backend. " + f"Import error: {_SERIAL_IMPORT_ERROR}" ) self.com_port = com_port @@ -113,7 +111,7 @@ async def tilt_move_to_relative_step_position(self, steps: float): .. warning:: This method has the potential to decalibrate the tilt module. Args: - steps: the number of steps (±10000) + steps: the number of steps (+-10000) """ assert -10000 <= steps <= 10000, "Steps must be between -10000 and 10000." @@ -157,16 +155,11 @@ async def tilt_request_error(self) -> Optional[str]: return await self.send_command("RE") async def tilt_request_sensor(self) -> Optional[str]: - """It is unclear what this method does. The documentation lists the following map: - - 0 = LS 1 Input - 1 = LS 2 Input - 2 = LS 3 Input - 3 = PNP Input 1 - 4 = PNP Input 2 - 5 = PNP Input 3 - 6 = NPN Input 1 - 7 = NPN Input 2 + """Request sensor status. + + 0 = LS 1 Input, 1 = LS 2 Input, 2 = LS 3 Input, + 3 = PNP Input 1, 4 = PNP Input 2, 5 = PNP Input 3, + 6 = NPN Input 1, 7 = NPN Input 2 """ resp = await self.send_command(command="RX") @@ -188,22 +181,18 @@ async def tilt_request_sensor(self) -> Optional[str]: }[code] raise RuntimeError(f"Unexpected error code: {code}") - async def tilt_request_offset_between_light_barrier_and_init_position( - self, - ) -> int: + async def tilt_request_offset_between_light_barrier_and_init_position(self) -> int: """Request Offset between Light Barrier and Init Position""" resp = await self.send_command(command="RO") resp = resp[:-2].split(" ")[1] return int(resp) - # Open Collectors - async def tilt_port_set_open_collector(self, open_collector: int): - """Port set open collector + """Port set open collector. Args: - open_collector: 1...8 # TODO: what? + open_collector: 1...8 """ assert 1 <= open_collector <= 8, "open_collector must be between 1 and 8" @@ -211,23 +200,21 @@ async def tilt_port_set_open_collector(self, open_collector: int): return await self.send_command(command="PS", parameter=str(open_collector)) async def tilt_port_clear_open_collector(self, open_collector: int): - """Tilt port clear open collector + """Tilt port clear open collector. Args: - open_collector: 1...8 # TODO: what? + open_collector: 1...8 """ assert 1 <= open_collector <= 8, "open_collector must be between 1 and 8" return await self.send_command(command="PC", parameter=str(open_collector)) - # Single Commands **with** **Option** "Heating": - async def tilt_set_temperature(self, temperature: float): - """Tilt set the temperature 10.. 50 Grad C [1/10 Grad C] + """Set the temperature (10-50 degrees C). Args: - temperature: temperature in Celsisu, between 10 and 50 + temperature: temperature in Celsius, between 10 and 50 """ assert 10 <= temperature <= 50, "Temperature must be between 10 and 50." @@ -237,11 +224,7 @@ async def tilt_set_temperature(self, temperature: float): async def tilt_switch_off_temperature_controller(self): """Switch off the temperature controller on the tilt module.""" - return await self.send_command( - command="TO", - ) - - # Single Commands **with** **Option** "Waste Pump (PWM2)": + return await self.send_command(command="TO") async def tilt_set_drain_time(self, drain_time: float): """Set the drain time on the tilt module. @@ -255,20 +238,14 @@ async def tilt_set_drain_time(self, drain_time: float): return await self.send_command(command="DT", parameter=str(int(drain_time * 10))) async def tilt_set_waste_pump_on(self): - """Turn the waste pump on the tilt module on""" + """Turn the waste pump on.""" - return await self.send_command( - command="WP", - ) + return await self.send_command(command="WP") async def tilt_set_waste_pump_off(self): - """Turn the waste pump on the tilt module off""" - - return await self.send_command( - command="WO", - ) + """Turn the waste pump off.""" - # Adjustment Commands: + return await self.send_command(command="WO") async def tilt_set_name(self, name: str): """Set the tilt module name. @@ -282,19 +259,19 @@ async def tilt_set_name(self, name: str): return await self.send_command(command="MN", parameter=name) async def tilt_switch_encoder(self, on: bool): - """Switch the encoder on the tilt module on or off. + """Switch the encoder on or off. Args: - on: if `True`, the encoder will be turned on, else, it will be turned off. + on: if True, the encoder will be turned on, else off. """ return await self.send_command(command="EN", parameter=str(int(on))) async def tilt_initial_offset(self, offset: int): - """Set the initial offset on the tilt module + """Set the initial offset on the tilt module. Args: - offset: the initial offset steps, steps between -100 and 100 + offset: the initial offset steps, between -100 and 100 """ assert -100 <= offset <= 100, "Offset must be between -100 and 100." @@ -304,10 +281,10 @@ async def tilt_initial_offset(self, offset: int): class HamiltonTiltModuleChatterboxBackend(HamiltonTiltModuleBackend): async def setup(self, initial_offset=0): - print(f"[tilter] setup initial offset {initial_offset}") + pass async def stop(self): - print("[tilter] stopping") + pass async def send_command(self, command, parameter=None): - print(f"[tilter] Sending command: {command} with parameter: {parameter}") + pass diff --git a/pylabrobot/hamilton/tilt_module/tilt_module.py b/pylabrobot/hamilton/tilt_module/tilt_module.py new file mode 100644 index 00000000000..d3a19f94b70 --- /dev/null +++ b/pylabrobot/hamilton/tilt_module/tilt_module.py @@ -0,0 +1,152 @@ +import math +from typing import List, Optional + +from pylabrobot.capabilities.tilting import TiltingCapability +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, Plate +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.well import CrossSectionType, Well + +from .backend import HamiltonTiltModuleBackend + + +class HamiltonTiltModule(ResourceHolder, Device): + """A Hamilton tilt module.""" + + def __init__( + self, + name: str, + com_port: str, + child_location: Coordinate = Coordinate(1.0, 3.0, 83.55), + pedestal_size_z: float = 3.47, + write_timeout: float = 3, + timeout: float = 3, + ): + backend = HamiltonTiltModuleBackend( + com_port=com_port, + write_timeout=write_timeout, + timeout=timeout, + ) + ResourceHolder.__init__( + self, + name=name, + size_x=132, + size_y=92.57, + size_z=85.81, + child_location=child_location, + category="tilter", + model="HamiltonTiltModule", + ) + Device.__init__(self, backend=backend) + self._backend: HamiltonTiltModuleBackend = backend + self.pedestal_size_z = pedestal_size_z + self._hinge_coordinate = Coordinate(6.18, 0, 72.85) + + self.tilter = TiltingCapability(backend=backend) + self._capabilities = [self.tilter] + + @property + def hinge_coordinate(self) -> Coordinate: + return self._hinge_coordinate + + def rotate_coordinate_around_hinge( + self, absolute_coordinate: Coordinate, angle: float + ) -> Coordinate: + """Rotate an absolute coordinate around the hinge by a given angle. + + Args: + absolute_coordinate: The coordinate to rotate. + angle: The angle to rotate by, in degrees. Negative is clockwise. + """ + theta = math.radians(angle) + origin = self.get_absolute_location("l", "f", "b") + + rotation_arm_x = absolute_coordinate.x - (self._hinge_coordinate.x + origin.x) + rotation_arm_z = absolute_coordinate.z - (self._hinge_coordinate.z + origin.z) + + x_prime = rotation_arm_x * math.cos(theta) - rotation_arm_z * math.sin(theta) + z_prime = rotation_arm_x * math.sin(theta) + rotation_arm_z * math.cos(theta) + + new_x = x_prime + (self._hinge_coordinate.x + origin.x) + new_z = z_prime + (self._hinge_coordinate.z + origin.z) + + return Coordinate(new_x, absolute_coordinate.y, new_z) + + def get_plate_drain_offsets( + self, plate: Plate, absolute_angle: Optional[float] = None + ) -> List[Coordinate]: + """Get drain edge offsets for all wells in the plate at the given tilt angle. + + Args: + plate: The plate to calculate the offsets for. + absolute_angle: The absolute angle. If None, uses the current tilt angle. + """ + if absolute_angle is None: + absolute_angle = self.tilter.absolute_angle + angle = absolute_angle if self._hinge_coordinate.x < self._size_x / 2 else -absolute_angle + hinge_side = "l" if self._hinge_coordinate.x < self._size_x / 2 else "r" + + well_drain_offsets = [] + for well in plate.children: + level_coord = well.get_absolute_location(hinge_side, "c", "b") + rotated_coord = self.rotate_coordinate_around_hinge(level_coord, angle) + offset = rotated_coord - well.get_absolute_location("c", "c", "b") + well_drain_offsets.append(offset) + + return well_drain_offsets + + def get_well_drain_offsets( + self, + wells: List[Well], + n_tips: int = 1, + absolute_angle: Optional[float] = None, + ) -> List[Coordinate]: + """Get drain edge offsets for the given wells at the given tilt angle. + + Args: + wells: The wells to calculate the offsets for. + n_tips: The number of tips per well. Defaults to 1. + absolute_angle: The absolute angle. If None, uses the current tilt angle. + """ + if absolute_angle is None: + absolute_angle = self.tilter.absolute_angle + angle = absolute_angle * (-1 if self._hinge_coordinate.x >= self._size_x / 2 else 1) + + hinge_on_left = self._hinge_coordinate.x < self._size_x / 2 + min_tip_distance = 9 # mm + + well_drain_offsets = [] + for well in wells: + assert well.cross_section_type == CrossSectionType.CIRCLE, ( + "Wells must have circular cross-section" + ) + + diameter = well.get_absolute_size_x() + radius = diameter / 2 + + if n_tips > 1: + assert (n_tips - 1) * min_tip_distance <= diameter, ( + f"Cannot fit {n_tips} tips in a well with diameter {diameter} mm" + ) + y_offsets = [ + ((n_tips - 1) / 2 - tip_index) * min_tip_distance for tip_index in range(n_tips) + ] + x_offset = math.sqrt(radius**2 - max(y_offsets) ** 2) + x_offset = -x_offset if hinge_on_left else x_offset + tip_coords = [Coordinate(x_offset, y, 0) for y in y_offsets] + else: + x_offset = -radius if hinge_on_left else radius + tip_coords = [Coordinate(x_offset, 0, 0)] + + offsets = [] + for tip_coord in tip_coords: + rotated_tip = self.rotate_coordinate_around_hinge( + well.get_absolute_location("c", "c", "b") + tip_coord, + angle, + ) + offset = rotated_tip - well.get_absolute_location("c", "c", "b") + offsets.append(offset) + + well_drain_offsets.append(offsets) + + return [offset for well_offsets in well_drain_offsets for offset in well_offsets] diff --git a/pylabrobot/heating_shaking/__init__.py b/pylabrobot/heating_shaking/__init__.py deleted file mode 100644 index a11c255c17f..00000000000 --- a/pylabrobot/heating_shaking/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""A hybrid between pylabrobot.shaking and pylabrobot.temperature_controlling""" - -from pylabrobot.heating_shaking.backend import HeaterShakerBackend -from pylabrobot.heating_shaking.bioshake_backend import BioShake -from pylabrobot.heating_shaking.chatterbox import HeaterShakerChatterboxBackend -from pylabrobot.heating_shaking.hamilton_backend import ( - HamiltonHeaterShakerBackend, - HamiltonHeaterShakerBox, -) -from pylabrobot.heating_shaking.heater_shaker import HeaterShaker -from pylabrobot.heating_shaking.inheco.thermoshake import ( - inheco_thermoshake, - inheco_thermoshake_ac, - inheco_thermoshake_rm, -) -from pylabrobot.heating_shaking.inheco.thermoshake_backend import InhecoThermoshakeBackend diff --git a/pylabrobot/heating_shaking/bioshake_backend.py b/pylabrobot/heating_shaking/bioshake_backend.py deleted file mode 100644 index 2816363b5a2..00000000000 --- a/pylabrobot/heating_shaking/bioshake_backend.py +++ /dev/null @@ -1,268 +0,0 @@ -import asyncio -import warnings - -from pylabrobot.heating_shaking.backend import HeaterShakerBackend -from pylabrobot.io.serial import Serial -from pylabrobot.machines.backend import MachineBackend - -try: - import serial - - HAS_SERIAL = True -except ImportError as e: - HAS_SERIAL = False - _SERIAL_IMPORT_ERROR = e - - -class BioShake(HeaterShakerBackend): - def __init__(self, port: str, timeout: int = 60): - if not HAS_SERIAL: - raise RuntimeError( - f"pyserial is required for the BioShake module backend. Import error: {_SERIAL_IMPORT_ERROR}" - ) - - self.setup_finished = False - self.port = port - self.timeout = timeout - self.io = Serial( - port=self.port, - baudrate=9600, - bytesize=serial.EIGHTBITS, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - write_timeout=10, - timeout=self.timeout, - human_readable_device_name="BioShake", - ) - - async def _send_command(self, cmd: str, delay: float = 0.5, timeout: float = 2): - try: - # Flush serial buffers for a clean start - await self.io.reset_input_buffer() - await self.io.reset_output_buffer() - - # Send the command - await self.io.write((cmd + "\r").encode("ascii")) - await asyncio.sleep(delay) - - # Read and decode the response with a timeout - try: - response = await asyncio.wait_for(self.io.readline(), timeout=timeout) - - except asyncio.TimeoutError: - raise RuntimeError(f"Timed out waiting for response to '{cmd}'") - - decoded = response.decode("ascii", errors="ignore").strip() - - # Parsing the response from the BioShake - - # No response at all - if not decoded: - raise RuntimeError(f"No response for '{cmd}'") - - # Device-specific errors - if decoded.startswith("e"): - raise RuntimeError(f"Device returned error for '{cmd}': '{decoded}'") - - if decoded.startswith("u ->"): - raise NotImplementedError(f"'{cmd}' not supported: '{decoded}'") - - # Standard OK - if decoded.lower().startswith("ok"): - return None - - # All other valid responses (e.g. temperature and remaining time) - return decoded - - except Exception as e: - raise RuntimeError(f"Unexpected error while sending '{cmd}': {type(e).__name__}: {e}") from e - - async def setup(self, skip_home: bool = False): - await MachineBackend.setup(self) - await self.io.setup() - if not skip_home: - # Reset first before homing it to ensure the device is ready for run - await self.reset() - # Additional seconds until next command can be send after reset - await asyncio.sleep(4) - # Now home the device - await self.home() - - async def stop(self): - await MachineBackend.stop(self) - await self.io.stop() - - async def reset(self): - # Reset the BioShake if stuck in "e" state - # Flush serial buffers for a clean start - await self.io.reset_input_buffer() - await self.io.reset_output_buffer() - - # Send the command - await self.io.write(("resetDevice\r").encode("ascii")) - - start = asyncio.get_event_loop().time() - max_seconds = 30 # How long a reset typically last - - while True: - # Break the loop if process takes longer than 30 seconds - if asyncio.get_event_loop().time() - start > max_seconds: - raise TimeoutError("Reset did not complete in time") - - try: - # Wait for each line with a timeout - response = await asyncio.wait_for(self.io.readline(), timeout=2) - decoded = response.decode("ascii", errors="ignore").strip() - await asyncio.sleep(0.1) - - if len(decoded) > 0: - # Stop when the final message arrives - if "Initialization complete" in decoded: - break - - except asyncio.TimeoutError: - # Keep polling if nothing arrives within timeout - continue - - async def home(self): - # Initialize the BioShake into home position - await self._send_command(cmd="shakeGoHome", delay=5) - - async def start_shaking(self, speed: float, acceleration: int = 0): - # Check if speed is an integer - if isinstance(speed, float): - if not speed.is_integer(): - raise ValueError(f"Speed must be a whole number, not {speed}") - speed = int(speed) - if not isinstance(speed, int): - raise TypeError( - f"Speed must be an integer or a whole number float, not {type(speed).__name__}" - ) - - # Get the min and max speed of the device to assert speed - min_speed = int(float(await self._send_command(cmd="getShakeMinRpm", delay=0.2))) - max_speed = int(float(await self._send_command(cmd="getShakeMaxRpm", delay=0.2))) - - assert min_speed <= speed <= max_speed, ( - f"Speed {speed} RPM is out of range. Allowed range is {min_speed}{max_speed} RPM" - ) - - # Set the speed of the shaker - set_speed_cmd = f"setShakeTargetSpeed{speed}" - await self._send_command(cmd=set_speed_cmd) - - # Check if accel is an integer - if isinstance(acceleration, float): - if not acceleration.is_integer(): # type: ignore[attr-defined] # mypy is retarded - raise ValueError(f"Acceleration must be a whole number, not {acceleration}") - acceleration = int(acceleration) - if not isinstance(acceleration, int): - raise TypeError( - f"Acceleration must be an integer or a whole number float, not {type(acceleration).__name__}" - ) - - # Get the min and max acceleration of the device to check bounds - min_accel = int(float(await self._send_command(cmd="getShakeAccelerationMin", delay=0.2))) - max_accel = int(float(await self._send_command(cmd="getShakeAccelerationMax", delay=0.2))) - - assert min_accel <= acceleration <= max_accel, ( - f"Acceleration {acceleration} seconds is out of range. Allowed range is {min_accel}-{max_accel} seconds" - ) - - # Set the acceleration of the shaker - set_accel_cmd = f"setShakeAcceleration{acceleration}" - await self._send_command(cmd=set_accel_cmd, delay=0.2) - - # Send the command to start shaking, either with or without duration - - await self._send_command(cmd="shakeOn", delay=0.2) - - async def shake(self, speed: float, acceleration: int = 0): - warnings.warn( - "BioShake.shake() is deprecated and will be removed in a future release. " - "Use start_shaking() instead.", - DeprecationWarning, - stacklevel=2, - ) - await self.start_shaking(speed=speed, acceleration=acceleration) - - async def stop_shaking(self, deceleration: int = 0): - # Check if decel is an integer - if isinstance(deceleration, float): - if not deceleration.is_integer(): # type: ignore[attr-defined] # mypy is retarded - raise ValueError(f"Deceleration must be a whole number, not {deceleration}") - deceleration = int(deceleration) - if not isinstance(deceleration, int): - raise TypeError( - f"Deceleration must be an integer or a whole number float, not {type(deceleration).__name__}" - ) - - # Get the min and max decel of the device to asset decel - min_decel = int(float(await self._send_command(cmd="getShakeAccelerationMin", delay=0.2))) - max_decel = int(float(await self._send_command(cmd="getShakeAccelerationMax", delay=0.2))) - - assert min_decel <= deceleration <= max_decel, ( - f"Deceleration {deceleration} seconds is out of range. Allowed range is {min_decel}-{max_decel} seconds" - ) - - # Set the deceleration of the shaker - set_decel_cmd = f"setShakeAcceleration{deceleration}" - await self._send_command(cmd=set_decel_cmd, delay=0.2) - - # stop shaking - await self._send_command(cmd="shakeOff", delay=0.2) - - # The BioShake 3000 ELM firmware needs the motor to fully decelerate - # before the edge-locking mechanism (ELM) can operate. Without this - # delay, subsequent setElmUnlockPos commands return 'e' (error). - sleep_time_after_stop = 3 - await asyncio.sleep(sleep_time_after_stop) - - @property - def supports_locking(self) -> bool: - return True - - async def lock_plate(self): - await self._send_command(cmd="setElmLockPos", delay=0.3) - - async def unlock_plate(self): - await self._send_command(cmd="setElmUnlockPos", delay=0.3) - - @property - def supports_active_cooling(self) -> bool: - return True - - async def set_temperature(self, temperature: float): - # Get the min and max set points of the device to assert temperature - min_temp = int(float(await self._send_command(cmd="getTempMin", delay=0.2))) - max_temp = int(float(await self._send_command(cmd="getTempMax", delay=0.2))) - - assert min_temp <= temperature <= max_temp, ( - f"Temperature {temperature} C is out of range. Allowed range is {min_temp}–{max_temp} C." - ) - - temperature = temperature * 10 - - # Check if temperature is an integer - if isinstance(temperature, float): - if not temperature.is_integer(): - raise ValueError(f"Temperature must be a whole number, not {temperature} (1/10 C)") - temperature = int(temperature) - if not isinstance(temperature, int): - raise TypeError( - f"Temperature must be an integer or a whole number float, not {type(temperature).__name__} (1/10 C)" - ) - - set_temp_cmd = f"setTempTarget{temperature}" - await self._send_command(cmd=set_temp_cmd, delay=0.2) - - # Start temperature control - await self._send_command(cmd="tempOn", delay=0.2) - - async def get_current_temperature(self) -> float: - response = await self._send_command(cmd="getTempActual", delay=0.2) - return float(response) - - async def deactivate(self): - # Stop temperature control - await self._send_command(cmd="tempOff", delay=0.2) diff --git a/pylabrobot/heating_shaking/chatterbox.py b/pylabrobot/heating_shaking/chatterbox.py deleted file mode 100644 index a3d7776cdca..00000000000 --- a/pylabrobot/heating_shaking/chatterbox.py +++ /dev/null @@ -1,9 +0,0 @@ -from pylabrobot.heating_shaking import HeaterShakerBackend -from pylabrobot.shaking import ShakerChatterboxBackend -from pylabrobot.temperature_controlling import TemperatureControllerChatterboxBackend - - -class HeaterShakerChatterboxBackend( - HeaterShakerBackend, ShakerChatterboxBackend, TemperatureControllerChatterboxBackend -): - pass diff --git a/pylabrobot/heating_shaking/inheco/thermoshake_backend.py b/pylabrobot/heating_shaking/inheco/thermoshake_backend.py deleted file mode 100644 index 76efc556b36..00000000000 --- a/pylabrobot/heating_shaking/inheco/thermoshake_backend.py +++ /dev/null @@ -1,89 +0,0 @@ -import warnings - -from pylabrobot.heating_shaking.backend import HeaterShakerBackend -from pylabrobot.temperature_controlling.inheco.temperature_controller import ( - InhecoTemperatureControllerBackend, -) - - -class InhecoThermoshakeBackend(InhecoTemperatureControllerBackend, HeaterShakerBackend): - """Backend for Inheco Thermoshake devices - - https://www.inheco.com/thermoshake-ac.html - """ - - async def stop(self): - await self.stop_shaking() - await super().stop() - - async def _start_shaking_command(self): - """Send the device command that starts shaking with the configured settings.""" - - return await self.interface.send_command(f"{self.index}ASE1") - - async def stop_shaking(self): - """Stop shaking the device""" - - return await self.interface.send_command(f"{self.index}ASE0") - - async def set_shaker_speed(self, speed: float): - """Set the shaker speed on the device, but do not start shaking yet. Use `start_shaking` for - that. - """ - - # # 60 ... 2000 - # # Thermoshake and Teleshake - assert speed in range(60, 2001), "Speed must be in the range 60 to 2000 RPM" - - # Thermoshake AC, Teleshake95 AC and Teleshake AC - # 150 ... 3000 - # assert speed in range(150, 3001), "Speed must be in the range 150 to 3000 RPM" - - return await self.interface.send_command(f"1SSR{speed}") - - async def set_shaker_shape(self, shape: int): - """Set the shape of the figure that should be shaked. - - Args: - shape: 0 = Circle anticlockwise, 1 = Circle clockwise, 2 = Up left down right, 3 = Up right - down left, 4 = Up-down, 5 = Left-right - """ - - assert shape in range(6), "Shape must be in the range 0 to 5" - - return await self.interface.send_command(f"1SSS{shape}") - - async def start_shaking(self, speed: float, shape: int = 0): - """Start shaking at the given speed. - - Args: - speed: Speed of shaking in revolutions per minute (RPM) - """ - - await self.set_shaker_speed(speed=speed) - await self.set_shaker_shape(shape=shape) - await self._start_shaking_command() - - async def shake(self, speed: float, shape: int = 0): - """Deprecated alias for start_shaking.""" - warnings.warn( - "InhecoThermoshakeBackend.shake() is deprecated and will be removed in a future release. " - "Use start_shaking() instead.", - DeprecationWarning, - stacklevel=2, - ) - await self.start_shaking(speed=speed, shape=shape) - - @property - def supports_locking(self) -> bool: - return False - - async def lock_plate(self): - raise NotImplementedError( - "Locking the plate is not implemented yet for Inheco ThermoShake devices. " - ) - - async def unlock_plate(self): - raise NotImplementedError( - "Unlocking the plate is not implemented yet for Inheco ThermoShake devices. " - ) diff --git a/pylabrobot/inheco/__init__.py b/pylabrobot/inheco/__init__.py new file mode 100644 index 00000000000..c930f5d97f4 --- /dev/null +++ b/pylabrobot/inheco/__init__.py @@ -0,0 +1,9 @@ +from .control_box import InhecoTECControlBox +from .cpac import InhecoCPAC, InhecoCPACBackend, inheco_cpac_ultraflat +from .thermoshake import ( + InhecoThermoShake, + InhecoThermoshakeBackend, + inheco_thermoshake, + inheco_thermoshake_ac, + inheco_thermoshake_rm, +) diff --git a/pylabrobot/temperature_controlling/inheco/control_box.py b/pylabrobot/inheco/control_box.py similarity index 97% rename from pylabrobot/temperature_controlling/inheco/control_box.py rename to pylabrobot/inheco/control_box.py index d4699232797..3437ac864b6 100644 --- a/pylabrobot/temperature_controlling/inheco/control_box.py +++ b/pylabrobot/inheco/control_box.py @@ -12,7 +12,10 @@ def __init__( serial_number=None, ): self.io = HID( - human_readable_device_name="Inheco Control Box", vid=vid, pid=pid, serial_number=serial_number + human_readable_device_name="Inheco TEC Control Box", + vid=vid, + pid=pid, + serial_number=serial_number, ) async def setup(self): diff --git a/pylabrobot/inheco/cpac.py b/pylabrobot/inheco/cpac.py new file mode 100644 index 00000000000..18f138b90df --- /dev/null +++ b/pylabrobot/inheco/cpac.py @@ -0,0 +1,144 @@ +import abc +import warnings +from typing import Optional + +from pylabrobot.capabilities.temperature_controlling import ( + TemperatureControlCapability, + TemperatureControllerBackend, +) +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, ResourceHolder + +from .control_box import InhecoTECControlBox + + +class InhecoTemperatureControllerBackend(TemperatureControllerBackend, metaclass=abc.ABCMeta): + """Universal backend for Inheco Temperature Controller devices such as ThermoShake and CPAC""" + + @property + def supports_active_cooling(self) -> bool: + return True + + def __init__(self, index: int, control_box: InhecoTECControlBox): + assert 1 <= index <= 6, "Index must be between 1 and 6 (inclusive)" + self.index = index + self.interface = control_box + + async def setup(self): + pass + + async def stop(self): + await self.stop_temperature_control() + + def serialize(self) -> dict: + warnings.warn("The interface is not serialized.") + return super().serialize() + + # -- temperature control + + async def set_temperature(self, temperature: float): + await self.set_target_temperature(temperature) + await self.start_temperature_control() + + async def get_current_temperature(self) -> float: + response = await self.interface.send_command(f"{self.index}RAT0") + return float(response) / 10 + + async def deactivate(self): + await self.stop_temperature_control() + + # --- firmware temp + + async def set_target_temperature(self, temperature: float): + temperature = int(temperature * 10) + await self.interface.send_command(f"{self.index}STT{temperature}") + + async def start_temperature_control(self): + """Start the temperature control""" + return await self.interface.send_command(f"{self.index}ATE1") + + async def stop_temperature_control(self): + """Stop the temperature control""" + return await self.interface.send_command(f"{self.index}ATE0") + + # --- firmware misc + + async def get_device_info(self, info_type: int): + """Get device information + + - 0 Bootstrap Version + - 1 Application Version + - 2 Serial number + - 3 Current hardware version + - 4 INHECO copyright + """ + + assert info_type in range(5), "Info type must be in the range 0 to 4" + return await self.interface.send_command(f"{self.index}RFV{info_type}") + + +class InhecoCPACBackend(InhecoTemperatureControllerBackend): + pass + + +class InhecoCPAC(ResourceHolder, Device): + """Inheco CPAC temperature controller. + + Example: + >>> from pylabrobot.inheco import InhecoCPAC, inheco_cpac_ultraflat + >>> cpac = inheco_cpac_ultraflat("cpac", control_box=box, index=1) + >>> await cpac.setup() + >>> await cpac.tc.set_temperature(37.0) + >>> await cpac.tc.get_temperature() + 37.0 + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: InhecoCPACBackend, + child_location: Coordinate, + category: str = "temperature_controller", + model: Optional[str] = None, + ): + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + child_location=child_location, + category=category, + model=model, + ) + Device.__init__(self, backend=backend) + self._backend: InhecoCPACBackend = backend + self.tc = TemperatureControlCapability(backend=backend) + self._capabilities = [self.tc] + + def serialize(self) -> dict: + return { + **Device.serialize(self), + **ResourceHolder.serialize(self), + } + + +def inheco_cpac_ultraflat(name: str, control_box: InhecoTECControlBox, index: int) -> InhecoCPAC: + """Inheco CPAC Ultraflat + 7000166, 7000190, 7000165 + + https://www.inheco.com/data/pdf/cpac-brochure-1013-1032-34.pdf + """ + + return InhecoCPAC( + name=name, + backend=InhecoCPACBackend(control_box=control_box, index=index), + size_x=113, # from spec + size_y=89, # from spec + size_z=129, # from spec + child_location=Coordinate(x=8, y=11, z=77), # x from spec, y and z measured + model=inheco_cpac_ultraflat.__name__, + ) diff --git a/pylabrobot/inheco/scila/__init__.py b/pylabrobot/inheco/scila/__init__.py new file mode 100644 index 00000000000..c3f41eff14a --- /dev/null +++ b/pylabrobot/inheco/scila/__init__.py @@ -0,0 +1,3 @@ +from .inheco_sila_interface import InhecoSiLAInterface +from .scila import SCILA +from .scila_backend import DrawerStatus, SCILABackend diff --git a/pylabrobot/storage/inheco/scila/inheco_sila_interface.py b/pylabrobot/inheco/scila/inheco_sila_interface.py similarity index 76% rename from pylabrobot/storage/inheco/scila/inheco_sila_interface.py rename to pylabrobot/inheco/scila/inheco_sila_interface.py index 86388ae337c..db663ed57cf 100644 --- a/pylabrobot/storage/inheco/scila/inheco_sila_interface.py +++ b/pylabrobot/inheco/scila/inheco_sila_interface.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import datetime import http.server import logging import random @@ -13,13 +12,7 @@ from dataclasses import dataclass from typing import Any, Optional, Tuple -from pylabrobot.storage.inheco.scila.soap import ( - XSI, - _localname, - soap_body_payload, - soap_decode, - soap_encode, -) +from pylabrobot.inheco.scila.soap import XSI, soap_decode, soap_encode SOAP_RESPONSE_ResponseEventResponse = """ """ -SOAP_RESPONSE_DataEventResponse = """ - - - - 1 - Success - PT0S - 0 - - - -""" - - def _get_local_ip(machine_ip: str) -> str: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: @@ -85,7 +63,6 @@ def __init__(self, code: int, message: str, command: str, details: Optional[dict self.message = message self.command = command self.details = details or {} - super().__init__(f"Command {command} failed with code {code}: '{message}'") class InhecoSiLAInterface: @@ -227,50 +204,33 @@ async def _on_http(self, req: _HTTPRequest) -> bytes: cmd = self._pending - try: - xml_str = req.body.decode("utf-8") - payload = soap_body_payload(xml_str) - tag_local = _localname(payload.tag) - - if cmd is not None and not cmd.fut.done() and tag_local == "ResponseEvent": - response_event = soap_decode(xml_str) - if response_event["ResponseEvent"].get("requestId") == cmd.request_id: - ret = response_event["ResponseEvent"].get("returnValue", {}) - rc = ret.get("returnCode") - if rc != 3: # 3=Success + if cmd is not None and not cmd.fut.done(): + response_event = soap_decode(req.body.decode("utf-8")) + if "ResponseEvent" in response_event: + request_id = response_event["ResponseEvent"].get("requestId") + if request_id != cmd.request_id: + self._logger.warning("Request ID does not match pending command.") + else: + return_value = response_event["ResponseEvent"].get("returnValue", {}) + return_code = return_value.get("returnCode") + if return_code != 3: # error + err_msg = return_value.get("message", "Unknown error").replace("\n", " ") cmd.fut.set_exception( - SiLAError(rc, ret.get("message", "").replace(chr(10), " "), cmd.name, details=ret) + RuntimeError(f"Command {cmd.name} failed with code {return_code}: '{err_msg}'") ) else: - cmd.fut.set_result( - ET.fromstring(d) - if (d := response_event["ResponseEvent"].get("responseData")) - else ET.Element("EmptyResponse") - ) - - if tag_local == "DataEvent": - try: - raw = next(e.text for e in payload.iter() if _localname(e.tag) == "dataValue") - any_data_elem = ET.fromstring(raw).find(".//AnyData") # type: ignore[arg-type] - assert any_data_elem is not None and any_data_elem.text is not None - series = ET.fromstring(any_data_elem.text).findall(".//dataSeries") - data = {} - for s in series: - val = s.findall(".//integerValue")[-1].text - unit = s.get("unit") - data[s.get("nameId")] = f"{val} {unit}" if unit else val - print(f"[{datetime.datetime.now().strftime('%H:%M:%S.%f')[:-3]}] [SiLA DataEvent] {data}") - except Exception: - pass - return SOAP_RESPONSE_DataEventResponse.encode("utf-8") - - if tag_local == "StatusEvent": - return SOAP_RESPONSE_StatusEventResponse.encode("utf-8") - return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + response_data = response_event["ResponseEvent"].get("responseData", "") + root = ET.fromstring(response_data) + cmd.fut.set_result(root) + else: + self._logger.warning("No pending command to match response to.") - except Exception as e: - self._logger.error(f"Error handling event: {e}") + if "ResponseEvent" in req.body.decode("utf-8"): return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") + if "StatusEvent" in req.body.decode("utf-8"): + return SOAP_RESPONSE_StatusEventResponse.encode("utf-8") + self._logger.warning("Unknown event type received.") + return SOAP_RESPONSE_ResponseEventResponse.encode("utf-8") def _get_return_code_and_message(self, command_name: str, response: Any) -> Tuple[int, str]: resp_level = response.get(f"{command_name}Response", {}) # first level diff --git a/pylabrobot/inheco/scila/scila.py b/pylabrobot/inheco/scila/scila.py new file mode 100644 index 00000000000..f736632f1e0 --- /dev/null +++ b/pylabrobot/inheco/scila/scila.py @@ -0,0 +1,18 @@ +from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.device import Device + +from .scila_backend import SCILABackend + + +class SCILA(Device): + """Inheco SCILA incubator with 4 drawers and temperature control.""" + + def __init__(self, name: str, backend: SCILABackend): + raise NotImplementedError("SCILA is missing resource definition.") + Device.__init__(self, backend=backend) + self._backend: SCILABackend = backend + self.tc = TemperatureControlCapability(backend=backend) + self._capabilities = [self.tc] + + def serialize(self) -> dict: + return Device.serialize(self) diff --git a/pylabrobot/storage/inheco/scila/scila_backend.py b/pylabrobot/inheco/scila/scila_backend.py similarity index 85% rename from pylabrobot/storage/inheco/scila/scila_backend.py rename to pylabrobot/inheco/scila/scila_backend.py index 4f046ed67ff..0a296032a59 100644 --- a/pylabrobot/storage/inheco/scila/scila_backend.py +++ b/pylabrobot/inheco/scila/scila_backend.py @@ -1,8 +1,10 @@ import xml.etree.ElementTree as ET from typing import Any, Dict, Literal, Optional -from pylabrobot.machines.backend import MachineBackend -from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface +from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend +from pylabrobot.device import DeviceBackend + +from .inheco_sila_interface import InhecoSiLAInterface def _parse_scalar(text: Optional[str], tag: str) -> object: @@ -16,14 +18,14 @@ def _parse_scalar(text: Optional[str], tag: str) -> object: return int(s) if t in ("boolean", "bool"): return s.lower() == "true" - return s # String or unknown => raw text + return s def _get_param(root: ET.Element, name: str): p = root.find(f".//Parameter[@name='{name}']") if p is None or len(p) == 0: raise RuntimeError(f"Response missing parameter '{name}'") - child = next(iter(p)) # e.g. , , + child = next(iter(p)) return _parse_scalar(child.text, child.tag) @@ -34,15 +36,22 @@ def _get_params(root: ET.Element, names: list[str]) -> dict[str, object]: DrawerStatus = Literal["Opened", "Closed"] -class SCILABackend(MachineBackend): +class SCILABackend(TemperatureControllerBackend): + """Backend for Inheco SciLa incubators. + + Communicates over HTTP/SOAP via the SiLA interface. + """ + def __init__(self, scila_ip: str, client_ip: Optional[str] = None) -> None: self._sila_interface = InhecoSiLAInterface(client_ip=client_ip, machine_ip=scila_ip) async def setup(self) -> None: + await DeviceBackend.setup(self) await self._sila_interface.setup() await self._reset_and_initialize() async def stop(self) -> None: + await DeviceBackend.stop(self) await self._sila_interface.close() async def _reset_and_initialize(self) -> None: @@ -50,12 +59,11 @@ async def _reset_and_initialize(self) -> None: await self._sila_interface.send_command( command="Reset", deviceId="MyController", eventReceiverURI=event_uri, simulationMode=False ) - await self._sila_interface.send_command("Initialize") + # -- status queries -- + async def request_status(self) -> str: - # GetStatus returns synchronously (return_code 1 = immediate dict), unlike other commands - # which return asynchronously (return_code 2 = XML via callback). resp = await self._sila_interface.send_command("GetStatus") return resp.get("GetStatusResponse", {}).get("state", "Unknown") # type: ignore @@ -63,18 +71,7 @@ async def request_liquid_level(self) -> str: root = await self._sila_interface.send_command("GetLiquidLevel") return _get_param(root, "LiquidLevel") # type: ignore - async def request_temperature_information(self) -> dict[str, Any]: - root = await self._sila_interface.send_command("GetTemperature") - return _get_params(root, ["CurrentTemperature", "TargetTemperature", "TemperatureControl"]) # type: ignore - - async def measure_temperature(self) -> float: - return (await self.request_temperature_information())["CurrentTemperature"] # type: ignore - - async def request_target_temperature(self) -> float: - return (await self.request_temperature_information())["TargetTemperature"] # type: ignore - - async def is_temperature_control_enabled(self) -> bool: - return (await self.request_temperature_information())["TemperatureControl"] # type: ignore + # -- drawers -- async def open(self, drawer_id: int) -> None: if drawer_id not in {1, 2, 3, 4}: @@ -99,32 +96,45 @@ async def request_drawer_status(self, drawer_id: int) -> DrawerStatus: positions = await self.request_drawer_statuses() return positions[drawer_id] + # -- CO2 / valves -- + async def request_co2_flow_status(self) -> str: root = await self._sila_interface.send_command("GetCO2FlowStatus") return _get_param(root, "CO2FlowStatus") # type: ignore async def request_valve_status(self) -> dict[str, str]: - """ - example: - - { - "H2O": "Opened", - "CO2 Normal": "Opened", - "CO2 Boost": "Closed" - } - """ - root = await self._sila_interface.send_command("GetValveStatus") return _get_params(root, ["H2O", "CO2 Normal", "CO2 Boost"]) # type: ignore - async def start_temperature_control(self, temperature: float) -> None: + # -- TemperatureControllerBackend -- + + @property + def supports_active_cooling(self) -> bool: + return False + + async def request_temperature_information(self) -> dict[str, Any]: + root = await self._sila_interface.send_command("GetTemperature") + return _get_params(root, ["CurrentTemperature", "TargetTemperature", "TemperatureControl"]) # type: ignore + + async def set_temperature(self, temperature: float) -> None: await self._sila_interface.send_command( "SetTemperature", targetTemperature=temperature, temperatureControl=True ) - async def stop_temperature_control(self) -> None: + async def get_current_temperature(self) -> float: + return (await self.request_temperature_information())["CurrentTemperature"] # type: ignore + + async def deactivate(self) -> None: await self._sila_interface.send_command("SetTemperature", temperatureControl=False) + async def request_target_temperature(self) -> float: + return (await self.request_temperature_information())["TargetTemperature"] # type: ignore + + async def is_temperature_control_enabled(self) -> bool: + return (await self.request_temperature_information())["TemperatureControl"] # type: ignore + + # -- serialization -- + def serialize(self) -> dict[str, Any]: return { **super().serialize(), diff --git a/pylabrobot/inheco/scila/scila_backend_tests.py b/pylabrobot/inheco/scila/scila_backend_tests.py new file mode 100644 index 00000000000..9bc7570d0b4 --- /dev/null +++ b/pylabrobot/inheco/scila/scila_backend_tests.py @@ -0,0 +1,237 @@ +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import AsyncMock, patch + +from pylabrobot.inheco.scila.inheco_sila_interface import InhecoSiLAInterface +from pylabrobot.inheco.scila.scila_backend import SCILABackend + + +class TestSCILABackend(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.patcher = patch("pylabrobot.inheco.scila.scila_backend.InhecoSiLAInterface") + self.MockInhecoSiLAInterface = self.patcher.start() + self.mock_sila_interface = AsyncMock(spec=InhecoSiLAInterface) + self.mock_sila_interface.bound_port = 80 + self.mock_sila_interface.client_ip = "127.0.0.1" + self.MockInhecoSiLAInterface.return_value = self.mock_sila_interface + self.backend = SCILABackend(scila_ip="127.0.0.1") + + def tearDown(self): + self.patcher.stop() + + async def test_setup(self): + await self.backend.setup() + self.mock_sila_interface.setup.assert_called_once() + self.mock_sila_interface.send_command.assert_any_call( + command="Reset", + deviceId="MyController", + eventReceiverURI="http://127.0.0.1:80/", + simulationMode=False, + ) + self.mock_sila_interface.send_command.assert_any_call("Initialize") + + async def test_stop(self): + await self.backend.setup() + await self.backend.stop() + self.mock_sila_interface.close.assert_called_once() + + async def test_request_status(self): + self.mock_sila_interface.send_command.return_value = {"GetStatusResponse": {"state": "standBy"}} + status = await self.backend.request_status() + self.assertEqual(status, "standBy") + self.mock_sila_interface.send_command.assert_called_with("GetStatus") + + async def test_request_liquid_level(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "High" + ) + level = await self.backend.request_liquid_level() + self.assertEqual(level, "High") + self.mock_sila_interface.send_command.assert_called_with("GetLiquidLevel") + + async def test_request_temperature_information(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " 25.0" + " 37.0" + " true" + "" + ) + info = await self.backend.request_temperature_information() + self.assertEqual( + info, {"CurrentTemperature": 25.0, "TargetTemperature": 37.0, "TemperatureControl": True} + ) + self.mock_sila_interface.send_command.assert_called_with("GetTemperature") + + async def test_get_current_temperature(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " 25.0" + " 37.0" + " true" + "" + ) + temp = await self.backend.get_current_temperature() + self.assertEqual(temp, 25.0) + + async def test_request_target_temperature(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " 25.0" + " 37.0" + " true" + "" + ) + temp = await self.backend.request_target_temperature() + self.assertEqual(temp, 37.0) + + async def test_is_temperature_control_enabled(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " 25.0" + " 37.0" + " true" + "" + ) + enabled = await self.backend.is_temperature_control_enabled() + self.assertIs(enabled, True) + + async def test_open(self): + for drawer_id in [1, 2, 3, 4]: + with self.subTest(drawer_id=drawer_id): + self.mock_sila_interface.send_command.reset_mock() + await self.backend.open(drawer_id) + self.mock_sila_interface.send_command.assert_any_call("PrepareForInput", position=drawer_id) + self.mock_sila_interface.send_command.assert_any_call("OpenDoor") + + async def test_open_invalid_id(self): + with self.assertRaises(ValueError): + await self.backend.open(5) + + async def test_close(self): + for drawer_id in [1, 2, 3, 4]: + with self.subTest(drawer_id=drawer_id): + self.mock_sila_interface.send_command.reset_mock() + await self.backend.close(drawer_id) + self.mock_sila_interface.send_command.assert_any_call( + "PrepareForOutput", position=drawer_id + ) + self.mock_sila_interface.send_command.assert_any_call("CloseDoor") + + async def test_close_invalid_id(self): + with self.assertRaises(ValueError): + await self.backend.close(5) + + async def test_request_drawer_status(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " Opened" + " Closed" + " Opened" + " Closed" + "" + ) + positions = await self.backend.request_drawer_statuses() + self.assertEqual( + positions, + { + 1: "Opened", + 2: "Closed", + 3: "Opened", + 4: "Closed", + }, + ) + self.mock_sila_interface.send_command.assert_called_with("GetDoorStatus") + + async def test_request_drawer_status_single(self): + for drawer_id, expected_position in [ + (1, "Opened"), + (2, "Closed"), + (3, "Opened"), + (4, "Closed"), + ]: + with self.subTest(drawer_id=drawer_id): + self.mock_sila_interface.send_command.reset_mock() + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " Opened" + " Closed" + " Opened" + " Closed" + "" + ) + position = await self.backend.request_drawer_status(drawer_id) + self.assertEqual(position, expected_position) + + async def test_request_drawer_status_invalid_id(self): + with self.assertRaises(ValueError): + await self.backend.request_drawer_status(5) + + async def test_request_co2_flow_status(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "OK" + ) + status = await self.backend.request_co2_flow_status() + self.assertEqual(status, "OK") + self.mock_sila_interface.send_command.assert_called_with("GetCO2FlowStatus") + + async def test_request_valve_status(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " Opened" + " Opened" + " Closed" + "" + ) + status = await self.backend.request_valve_status() + self.assertEqual( + status, + { + "H2O": "Opened", + "CO2 Normal": "Opened", + "CO2 Boost": "Closed", + }, + ) + self.mock_sila_interface.send_command.assert_called_with("GetValveStatus") + + async def test_set_temperature(self): + await self.backend.set_temperature(30.0) + self.mock_sila_interface.send_command.assert_called_with( + "SetTemperature", targetTemperature=30.0, temperatureControl=True + ) + + async def test_deactivate(self): + await self.backend.deactivate() + self.mock_sila_interface.send_command.assert_called_with( + "SetTemperature", temperatureControl=False + ) + + def test_serialize(self): + self.mock_sila_interface.machine_ip = "169.254.1.117" + self.mock_sila_interface.client_ip = "192.168.1.10" + data = self.backend.serialize() + self.assertEqual(data["scila_ip"], "169.254.1.117") + self.assertEqual(data["client_ip"], "192.168.1.10") + + def test_serialize_no_client_ip(self): + self.mock_sila_interface.machine_ip = "127.0.0.1" + self.mock_sila_interface.client_ip = None + data = self.backend.serialize() + self.assertEqual(data["scila_ip"], "127.0.0.1") + self.assertIsNone(data["client_ip"]) + + def test_deserialize(self): + data = {"scila_ip": "169.254.1.117", "client_ip": "192.168.1.10"} + SCILABackend.deserialize(data) + self.MockInhecoSiLAInterface.assert_called_with( + client_ip="192.168.1.10", machine_ip="169.254.1.117" + ) + + def test_deserialize_no_client_ip(self): + data = {"scila_ip": "169.254.1.117"} + SCILABackend.deserialize(data) + self.MockInhecoSiLAInterface.assert_called_with(client_ip=None, machine_ip="169.254.1.117") + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/storage/inheco/scila/soap.py b/pylabrobot/inheco/scila/soap.py similarity index 100% rename from pylabrobot/storage/inheco/scila/soap.py rename to pylabrobot/inheco/scila/soap.py diff --git a/pylabrobot/inheco/thermoshake.py b/pylabrobot/inheco/thermoshake.py new file mode 100644 index 00000000000..2cf6ea90426 --- /dev/null +++ b/pylabrobot/inheco/thermoshake.py @@ -0,0 +1,174 @@ +import warnings +from typing import Optional + +from pylabrobot.capabilities.shaking import ShakerBackend, ShakingCapability +from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, ResourceHolder + +from .control_box import InhecoTECControlBox +from .cpac import InhecoTemperatureControllerBackend + + +class InhecoThermoshakeBackend(InhecoTemperatureControllerBackend, ShakerBackend): + """Backend for Inheco Thermoshake devices. + + https://www.inheco.com/thermoshake-ac.html + """ + + async def stop(self): + await self.stop_shaking() + await super().stop() + + async def _start_shaking_command(self): + return await self.interface.send_command(f"{self.index}ASE1") + + async def stop_shaking(self): + return await self.interface.send_command(f"{self.index}ASE0") + + async def set_shaker_speed(self, speed: float): + assert speed in range(60, 2001), "Speed must be in the range 60 to 2000 RPM" + return await self.interface.send_command(f"1SSR{speed}") + + async def set_shaker_shape(self, shape: int): + """Set the shaking shape. + + Args: + shape: 0 = Circle anticlockwise, 1 = Circle clockwise, 2 = Up left down right, + 3 = Up right down left, 4 = Up-down, 5 = Left-right + """ + assert shape in range(6), "Shape must be in the range 0 to 5" + return await self.interface.send_command(f"1SSS{shape}") + + async def start_shaking(self, speed: float, shape: int = 0): + await self.set_shaker_speed(speed=speed) + await self.set_shaker_shape(shape=shape) + await self._start_shaking_command() + + async def shake(self, speed: float, shape: int = 0): + """Deprecated alias for start_shaking.""" + warnings.warn( + "InhecoThermoshakeBackend.shake() is deprecated. Use start_shaking() instead.", + DeprecationWarning, + stacklevel=2, + ) + await self.start_shaking(speed=speed, shape=shape) + + @property + def supports_locking(self) -> bool: + return False + + async def lock_plate(self): + raise NotImplementedError("Locking is not supported on Inheco ThermoShake devices.") + + async def unlock_plate(self): + raise NotImplementedError("Unlocking is not supported on Inheco ThermoShake devices.") + + +class InhecoThermoShake(ResourceHolder, Device): + """Inheco ThermoShake: combined temperature control and shaking. + + Example: + >>> from pylabrobot.inheco import InhecoThermoShake, inheco_thermoshake + >>> ts = inheco_thermoshake("ts", control_box=box, index=1) + >>> await ts.setup() + >>> await ts.tc.set_temperature(37.0) + >>> await ts.shaking.shake(speed=300) + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: InhecoThermoshakeBackend, + child_location: Coordinate, + category: str = "heating_shaking", + model: Optional[str] = None, + ): + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + child_location=child_location, + category=category, + model=model, + ) + Device.__init__(self, backend=backend) + self._backend: InhecoThermoshakeBackend = backend + self.tc = TemperatureControlCapability(backend=backend) + self.shaker = ShakingCapability(backend=backend) + self._capabilities = [self.tc, self.shaker] + + def serialize(self) -> dict: + return { + **Device.serialize(self), + **ResourceHolder.serialize(self), + } + + +def inheco_thermoshake_ac( + name: str, control_box: InhecoTECControlBox, index: int +) -> InhecoThermoShake: + """Inheco Thermoshake AC + + 7100160, 7100161 + + https://www.inheco.com/thermoshake-ac.html + """ + + raise NotImplementedError("Inheco ThermoShake AC is missing child_location.") + + return InhecoThermoShake( + name=name, + backend=InhecoThermoshakeBackend(control_box=control_box, index=index), + size_x=147, # from spec + size_y=104, # from spec + size_z=115.9, # from spec + child_location=Coordinate(x=0, y=0, z=109.9), # TODO + model=inheco_thermoshake_ac.__name__, + ) + + +def inheco_thermoshake( + name: str, control_box: InhecoTECControlBox, index: int +) -> InhecoThermoShake: + """Inheco Thermoshake (7100146) + + https://www.inheco.com/thermoshake-classic.html + """ + + return InhecoThermoShake( + name=name, + backend=InhecoThermoshakeBackend(control_box=control_box, index=index), + size_x=147, # from spec + size_y=104, # from spec + size_z=118, # from spec + child_location=Coordinate(x=9.62, y=9.22, z=109.9), # measured + model=inheco_thermoshake.__name__, + # pedestal_size_z=-4.2, # measured + ) + + +def inheco_thermoshake_rm( + name: str, control_box: InhecoTECControlBox, index: int +) -> InhecoThermoShake: + """Inheco Thermoshake RM (7100144) + + https://www.inheco.com/thermoshake-classic.html + """ + + raise NotImplementedError("Inheco Thermoshake RM is missing child_location") + + return InhecoThermoShake( + name=name, + backend=InhecoThermoshakeBackend(control_box=control_box, index=index), + size_x=147, # from spec + size_y=104, # from spec + size_z=116, # from spec + child_location=Coordinate(x=0, y=0, z=0), # TODO + model=inheco_thermoshake_rm.__name__, + ) diff --git a/pylabrobot/io/validation.py b/pylabrobot/io/validation.py index 01d9d749e83..58a47e3484c 100644 --- a/pylabrobot/io/validation.py +++ b/pylabrobot/io/validation.py @@ -1,11 +1,11 @@ from typing import Optional +from pylabrobot.device import DeviceBackend from pylabrobot.io.capture import CaptureReader, capturer from pylabrobot.io.ftdi import FTDI, FTDIValidator from pylabrobot.io.hid import HID, HIDValidator from pylabrobot.io.serial import Serial, SerialValidator from pylabrobot.io.usb import USB, USBValidator -from pylabrobot.machines.backend import MachineBackend cr: Optional[CaptureReader] = None @@ -40,7 +40,7 @@ def _replace_io(obj): return False return True - for machine_backend in MachineBackend.get_all_instances(): + for machine_backend in DeviceBackend.get_all_instances(): if not ( (hasattr(machine_backend, "io") and _replace_io(machine_backend)) or (hasattr(machine_backend, "interface") and _replace_io(machine_backend.interface)) diff --git a/pylabrobot/barcode_scanners/keyence/__init__.py b/pylabrobot/keyence/__init__.py similarity index 100% rename from pylabrobot/barcode_scanners/keyence/__init__.py rename to pylabrobot/keyence/__init__.py diff --git a/pylabrobot/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/keyence/keyence_backend.py similarity index 95% rename from pylabrobot/barcode_scanners/keyence/keyence_backend.py rename to pylabrobot/keyence/keyence_backend.py index e79501fda71..25e8aadb7a6 100644 --- a/pylabrobot/barcode_scanners/keyence/keyence_backend.py +++ b/pylabrobot/keyence/keyence_backend.py @@ -10,7 +10,7 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.barcode_scanners.backend import ( +from pylabrobot.capabilities.barcode_scanning.backend import ( BarcodeScannerBackend, BarcodeScannerError, ) @@ -74,7 +74,7 @@ async def initialize(self): async def send_command(self, command: str) -> str: """Send a command to the barcode scanner and return the response. - Keyence uses carriage return \r as the line ending by default.""" + Keyence uses carriage return \\r as the line ending by default.""" await self.io.write((command + "\r").encode(self.serial_messaging_encoding)) response = await self.io.read() diff --git a/pylabrobot/plate_reading/tecan/spark20m/__init__.py b/pylabrobot/legacy/__init__.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/__init__.py rename to pylabrobot/legacy/__init__.py diff --git a/pylabrobot/arms/__init__.py b/pylabrobot/legacy/arms/__init__.py similarity index 100% rename from pylabrobot/arms/__init__.py rename to pylabrobot/legacy/arms/__init__.py diff --git a/pylabrobot/arms/backend.py b/pylabrobot/legacy/arms/backend.py similarity index 96% rename from pylabrobot/arms/backend.py rename to pylabrobot/legacy/arms/backend.py index 8a6ebabd6f1..39f0feea437 100644 --- a/pylabrobot/arms/backend.py +++ b/pylabrobot/legacy/arms/backend.py @@ -2,8 +2,8 @@ from dataclasses import dataclass from typing import Dict, List, Optional, Union -from pylabrobot.arms.precise_flex.coords import PreciseFlexCartesianCoords -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.arms.precise_flex.coords import PreciseFlexCartesianCoords +from pylabrobot.legacy.machines.backend import MachineBackend @dataclass diff --git a/pylabrobot/arms/precise_flex/__init__.py b/pylabrobot/legacy/arms/precise_flex/__init__.py similarity index 100% rename from pylabrobot/arms/precise_flex/__init__.py rename to pylabrobot/legacy/arms/precise_flex/__init__.py diff --git a/pylabrobot/arms/precise_flex/coords.py b/pylabrobot/legacy/arms/precise_flex/coords.py similarity index 81% rename from pylabrobot/arms/precise_flex/coords.py rename to pylabrobot/legacy/arms/precise_flex/coords.py index 6d039c0d31a..dd172c40177 100644 --- a/pylabrobot/arms/precise_flex/coords.py +++ b/pylabrobot/legacy/arms/precise_flex/coords.py @@ -2,7 +2,7 @@ from enum import Enum from typing import Optional -from pylabrobot.arms.standard import CartesianCoords +from pylabrobot.legacy.arms.standard import CartesianCoords class ElbowOrientation(Enum): diff --git a/pylabrobot/arms/precise_flex/error_codes.py b/pylabrobot/legacy/arms/precise_flex/error_codes.py similarity index 100% rename from pylabrobot/arms/precise_flex/error_codes.py rename to pylabrobot/legacy/arms/precise_flex/error_codes.py diff --git a/pylabrobot/arms/precise_flex/joints.py b/pylabrobot/legacy/arms/precise_flex/joints.py similarity index 100% rename from pylabrobot/arms/precise_flex/joints.py rename to pylabrobot/legacy/arms/precise_flex/joints.py diff --git a/pylabrobot/arms/precise_flex/pf_3400.py b/pylabrobot/legacy/arms/precise_flex/pf_3400.py similarity index 76% rename from pylabrobot/arms/precise_flex/pf_3400.py rename to pylabrobot/legacy/arms/precise_flex/pf_3400.py index 152cc8a6b41..5b35474576b 100644 --- a/pylabrobot/arms/precise_flex/pf_3400.py +++ b/pylabrobot/legacy/arms/precise_flex/pf_3400.py @@ -1,4 +1,4 @@ -from pylabrobot.arms.precise_flex.precise_flex_backend import PreciseFlexBackend +from pylabrobot.legacy.arms.precise_flex.precise_flex_backend import PreciseFlexBackend class PreciseFlex3400Backend(PreciseFlexBackend): diff --git a/pylabrobot/arms/precise_flex/pf_400.py b/pylabrobot/legacy/arms/precise_flex/pf_400.py similarity index 76% rename from pylabrobot/arms/precise_flex/pf_400.py rename to pylabrobot/legacy/arms/precise_flex/pf_400.py index 524ab5020fe..b0a5ec0d837 100644 --- a/pylabrobot/arms/precise_flex/pf_400.py +++ b/pylabrobot/legacy/arms/precise_flex/pf_400.py @@ -1,4 +1,4 @@ -from pylabrobot.arms.precise_flex.precise_flex_backend import PreciseFlexBackend +from pylabrobot.legacy.arms.precise_flex.precise_flex_backend import PreciseFlexBackend class PreciseFlex400Backend(PreciseFlexBackend): diff --git a/pylabrobot/arms/precise_flex/precise_flex_backend.py b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py similarity index 99% rename from pylabrobot/arms/precise_flex/precise_flex_backend.py rename to pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py index b7acab98566..afba04dfe69 100644 --- a/pylabrobot/arms/precise_flex/precise_flex_backend.py +++ b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py @@ -3,16 +3,16 @@ from abc import ABC from typing import Dict, List, Literal, Optional, Union -from pylabrobot.arms.backend import ( +from pylabrobot.io.socket import Socket +from pylabrobot.legacy.arms.backend import ( AccessPattern, HorizontalAccess, SCARABackend, VerticalAccess, ) -from pylabrobot.arms.precise_flex.coords import ElbowOrientation, PreciseFlexCartesianCoords -from pylabrobot.arms.precise_flex.error_codes import ERROR_CODES -from pylabrobot.arms.precise_flex.joints import PFAxis -from pylabrobot.io.socket import Socket +from pylabrobot.legacy.arms.precise_flex.coords import ElbowOrientation, PreciseFlexCartesianCoords +from pylabrobot.legacy.arms.precise_flex.error_codes import ERROR_CODES +from pylabrobot.legacy.arms.precise_flex.joints import PFAxis from pylabrobot.resources import Coordinate, Rotation diff --git a/pylabrobot/arms/precise_flex/precise_flex_backend_tests.py b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend_tests.py similarity index 99% rename from pylabrobot/arms/precise_flex/precise_flex_backend_tests.py rename to pylabrobot/legacy/arms/precise_flex/precise_flex_backend_tests.py index 0742f7281e7..d61f5d0ba6e 100644 --- a/pylabrobot/arms/precise_flex/precise_flex_backend_tests.py +++ b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend_tests.py @@ -2,11 +2,14 @@ from typing import Dict from unittest.mock import AsyncMock, patch -from pylabrobot.arms.backend import HorizontalAccess, VerticalAccess -from pylabrobot.arms.precise_flex.coords import ElbowOrientation, PreciseFlexCartesianCoords -from pylabrobot.arms.precise_flex.joints import PFAxis -from pylabrobot.arms.precise_flex.precise_flex_backend import PreciseFlexBackend, PreciseFlexError from pylabrobot.io.socket import Socket # Import Socket for mocking +from pylabrobot.legacy.arms.backend import HorizontalAccess, VerticalAccess +from pylabrobot.legacy.arms.precise_flex.coords import ElbowOrientation, PreciseFlexCartesianCoords +from pylabrobot.legacy.arms.precise_flex.joints import PFAxis +from pylabrobot.legacy.arms.precise_flex.precise_flex_backend import ( + PreciseFlexBackend, + PreciseFlexError, +) from pylabrobot.resources import Coordinate, Rotation @@ -27,7 +30,7 @@ def setUp(self): # Patch the Socket class where it's used in PreciseFlexBackend patcher = patch( - "pylabrobot.arms.precise_flex.precise_flex_backend.Socket", + "pylabrobot.legacy.arms.precise_flex.precise_flex_backend.Socket", return_value=self.mock_socket_instance, ) self.MockSocketClass = patcher.start() # Store the mock of the class diff --git a/pylabrobot/arms/scara.py b/pylabrobot/legacy/arms/scara.py similarity index 95% rename from pylabrobot/arms/scara.py rename to pylabrobot/legacy/arms/scara.py index fed8345032d..72932f3db80 100644 --- a/pylabrobot/arms/scara.py +++ b/pylabrobot/legacy/arms/scara.py @@ -1,8 +1,8 @@ from typing import Dict, List, Optional, Union -from pylabrobot.arms.backend import AccessPattern, SCARABackend -from pylabrobot.arms.precise_flex.coords import PreciseFlexCartesianCoords -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.arms.backend import AccessPattern, SCARABackend +from pylabrobot.legacy.arms.precise_flex.coords import PreciseFlexCartesianCoords +from pylabrobot.legacy.machines.machine import Machine class ExperimentalSCARA(Machine): diff --git a/pylabrobot/arms/scara_tests.py b/pylabrobot/legacy/arms/scara_tests.py similarity index 94% rename from pylabrobot/arms/scara_tests.py rename to pylabrobot/legacy/arms/scara_tests.py index 4455abc104d..c3be1b27deb 100644 --- a/pylabrobot/arms/scara_tests.py +++ b/pylabrobot/legacy/arms/scara_tests.py @@ -1,9 +1,9 @@ import unittest from unittest.mock import AsyncMock, MagicMock -from pylabrobot.arms.backend import SCARABackend -from pylabrobot.arms.precise_flex.coords import PreciseFlexCartesianCoords -from pylabrobot.arms.scara import ExperimentalSCARA +from pylabrobot.legacy.arms.backend import SCARABackend +from pylabrobot.legacy.arms.precise_flex.coords import PreciseFlexCartesianCoords +from pylabrobot.legacy.arms.scara import ExperimentalSCARA from pylabrobot.resources import Coordinate, Rotation diff --git a/pylabrobot/arms/standard.py b/pylabrobot/legacy/arms/standard.py similarity index 100% rename from pylabrobot/arms/standard.py rename to pylabrobot/legacy/arms/standard.py diff --git a/pylabrobot/legacy/barcode_scanners/__init__.py b/pylabrobot/legacy/barcode_scanners/__init__.py new file mode 100644 index 00000000000..35ce71629e0 --- /dev/null +++ b/pylabrobot/legacy/barcode_scanners/__init__.py @@ -0,0 +1,6 @@ +"""Legacy. Use pylabrobot.capabilities.barcode_scanning instead.""" + +from pylabrobot.capabilities.barcode_scanning import BarcodeScannerBackend, BarcodeScannerError +from pylabrobot.keyence import KeyenceBarcodeScannerBackend + +from .barcode_scanner import BarcodeScanner diff --git a/pylabrobot/barcode_scanners/backend.py b/pylabrobot/legacy/barcode_scanners/backend.py similarity index 74% rename from pylabrobot/barcode_scanners/backend.py rename to pylabrobot/legacy/barcode_scanners/backend.py index 4a8b75fb9ae..957b0ddd3b7 100644 --- a/pylabrobot/barcode_scanners/backend.py +++ b/pylabrobot/legacy/barcode_scanners/backend.py @@ -1,6 +1,8 @@ +"""Legacy. Use pylabrobot.capabilities.barcode_scanning.backend instead.""" + from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.resources.barcode import Barcode diff --git a/pylabrobot/legacy/barcode_scanners/barcode_scanner.py b/pylabrobot/legacy/barcode_scanners/barcode_scanner.py new file mode 100644 index 00000000000..b3557daf2aa --- /dev/null +++ b/pylabrobot/legacy/barcode_scanners/barcode_scanner.py @@ -0,0 +1,20 @@ +"""Legacy. Use pylabrobot.capabilities.barcode_scanning instead.""" + +from pylabrobot.legacy.barcode_scanners.backend import BarcodeScannerBackend +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.resources.barcode import Barcode + + +class BarcodeScanner(Machine): + """Legacy standalone barcode scanner Machine. + + In new code, use BarcodeScanningCapability instead. + """ + + def __init__(self, backend: BarcodeScannerBackend): + super().__init__(backend=backend) + self.backend: BarcodeScannerBackend = backend + + async def scan(self) -> Barcode: + """Scan a barcode and return its value.""" + return await self.backend.scan_barcode() diff --git a/pylabrobot/legacy/barcode_scanners/keyence/__init__.py b/pylabrobot/legacy/barcode_scanners/keyence/__init__.py new file mode 100644 index 00000000000..862134c0271 --- /dev/null +++ b/pylabrobot/legacy/barcode_scanners/keyence/__init__.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.keyence instead.""" + +from pylabrobot.keyence import KeyenceBarcodeScannerBackend # noqa: F401 diff --git a/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py new file mode 100644 index 00000000000..808d59ecca4 --- /dev/null +++ b/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.keyence.KeyenceBarcodeScannerBackend instead.""" + +from pylabrobot.keyence.keyence_backend import KeyenceBarcodeScannerBackend # noqa: F401 diff --git a/pylabrobot/centrifuge/__init__.py b/pylabrobot/legacy/centrifuge/__init__.py similarity index 100% rename from pylabrobot/centrifuge/__init__.py rename to pylabrobot/legacy/centrifuge/__init__.py diff --git a/pylabrobot/centrifuge/access2.py b/pylabrobot/legacy/centrifuge/access2.py similarity index 80% rename from pylabrobot/centrifuge/access2.py rename to pylabrobot/legacy/centrifuge/access2.py index 8f773914ab7..a91eee917d9 100644 --- a/pylabrobot/centrifuge/access2.py +++ b/pylabrobot/legacy/centrifuge/access2.py @@ -1,7 +1,7 @@ from typing import Tuple -from pylabrobot.centrifuge.centrifuge import Centrifuge, Loader -from pylabrobot.centrifuge.vspin_backend import Access2Backend, VSpinBackend +from pylabrobot.legacy.centrifuge.centrifuge import Centrifuge, Loader +from pylabrobot.legacy.centrifuge.vspin_backend import Access2Backend, VSpinBackend from pylabrobot.resources import Coordinate diff --git a/pylabrobot/centrifuge/backend.py b/pylabrobot/legacy/centrifuge/backend.py similarity index 94% rename from pylabrobot/centrifuge/backend.py rename to pylabrobot/legacy/centrifuge/backend.py index b0429268204..0bd70c3b331 100644 --- a/pylabrobot/centrifuge/backend.py +++ b/pylabrobot/legacy/centrifuge/backend.py @@ -2,7 +2,7 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class CentrifugeBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/centrifuge/centrifuge.py b/pylabrobot/legacy/centrifuge/centrifuge.py similarity index 97% rename from pylabrobot/centrifuge/centrifuge.py rename to pylabrobot/legacy/centrifuge/centrifuge.py index 2476882e5ad..1fc55261690 100644 --- a/pylabrobot/centrifuge/centrifuge.py +++ b/pylabrobot/legacy/centrifuge/centrifuge.py @@ -1,15 +1,15 @@ import warnings from typing import Optional, Tuple -from pylabrobot.centrifuge.backend import CentrifugeBackend, LoaderBackend -from pylabrobot.centrifuge.standard import ( +from pylabrobot.legacy.centrifuge.backend import CentrifugeBackend, LoaderBackend +from pylabrobot.legacy.centrifuge.standard import ( BucketHasPlateError, BucketNoPlateError, CentrifugeDoorError, LoaderNoPlateError, NotAtBucketError, ) -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine from pylabrobot.resources import Coordinate, Resource, ResourceHolder from pylabrobot.resources.rotation import Rotation from pylabrobot.serializer import deserialize diff --git a/pylabrobot/centrifuge/centrifuge_tests.py b/pylabrobot/legacy/centrifuge/centrifuge_tests.py similarity index 93% rename from pylabrobot/centrifuge/centrifuge_tests.py rename to pylabrobot/legacy/centrifuge/centrifuge_tests.py index 9dbf6c56d8b..939300942f7 100644 --- a/pylabrobot/centrifuge/centrifuge_tests.py +++ b/pylabrobot/legacy/centrifuge/centrifuge_tests.py @@ -1,6 +1,6 @@ import unittest -from pylabrobot.centrifuge import ( +from pylabrobot.legacy.centrifuge import ( BucketHasPlateError, BucketNoPlateError, Centrifuge, @@ -9,8 +9,11 @@ LoaderNoPlateError, NotAtBucketError, ) -from pylabrobot.centrifuge.backend import CentrifugeBackend, LoaderBackend -from pylabrobot.centrifuge.chatterbox import CentrifugeChatterboxBackend, LoaderChatterboxBackend +from pylabrobot.legacy.centrifuge.backend import CentrifugeBackend, LoaderBackend +from pylabrobot.legacy.centrifuge.chatterbox import ( + CentrifugeChatterboxBackend, + LoaderChatterboxBackend, +) from pylabrobot.resources import Coordinate, Cor_96_wellplate_360ul_Fb @@ -136,7 +139,7 @@ async def test_unload_not_at_bucket(self): self.mock_loader_backend.unload.assert_not_awaited() def test_serialize(self): - self.loader.backend = LoaderChatterboxBackend() - self.centrifuge.backend = CentrifugeChatterboxBackend() + self.loader._backend = LoaderChatterboxBackend() + self.centrifuge._backend = CentrifugeChatterboxBackend() serialized = self.loader.serialize() self.assertEqual(Loader.deserialize(serialized), self.loader) diff --git a/pylabrobot/centrifuge/chatterbox.py b/pylabrobot/legacy/centrifuge/chatterbox.py similarity index 93% rename from pylabrobot/centrifuge/chatterbox.py rename to pylabrobot/legacy/centrifuge/chatterbox.py index 4f32d678473..6e74d270af2 100644 --- a/pylabrobot/centrifuge/chatterbox.py +++ b/pylabrobot/legacy/centrifuge/chatterbox.py @@ -1,4 +1,4 @@ -from pylabrobot.centrifuge.backend import CentrifugeBackend, LoaderBackend +from pylabrobot.legacy.centrifuge.backend import CentrifugeBackend, LoaderBackend class CentrifugeChatterboxBackend(CentrifugeBackend): diff --git a/pylabrobot/centrifuge/standard.py b/pylabrobot/legacy/centrifuge/standard.py similarity index 100% rename from pylabrobot/centrifuge/standard.py rename to pylabrobot/legacy/centrifuge/standard.py diff --git a/pylabrobot/centrifuge/vspin_backend.py b/pylabrobot/legacy/centrifuge/vspin_backend.py similarity index 100% rename from pylabrobot/centrifuge/vspin_backend.py rename to pylabrobot/legacy/centrifuge/vspin_backend.py diff --git a/pylabrobot/legacy/heating_shaking/__init__.py b/pylabrobot/legacy/heating_shaking/__init__.py new file mode 100644 index 00000000000..370a9d84750 --- /dev/null +++ b/pylabrobot/legacy/heating_shaking/__init__.py @@ -0,0 +1,16 @@ +"""A hybrid between pylabrobot.shaking and pylabrobot.temperature_controlling""" + +from pylabrobot.legacy.heating_shaking.backend import HeaterShakerBackend +from pylabrobot.legacy.heating_shaking.bioshake_backend import BioShake +from pylabrobot.legacy.heating_shaking.chatterbox import HeaterShakerChatterboxBackend +from pylabrobot.legacy.heating_shaking.hamilton_backend import ( + HamiltonHeaterShakerBackend, + HamiltonHeaterShakerBox, +) +from pylabrobot.legacy.heating_shaking.heater_shaker import HeaterShaker +from pylabrobot.legacy.heating_shaking.inheco.thermoshake import ( + inheco_thermoshake, + inheco_thermoshake_ac, + inheco_thermoshake_rm, +) +from pylabrobot.legacy.heating_shaking.inheco.thermoshake_backend import InhecoThermoshakeBackend diff --git a/pylabrobot/heating_shaking/backend.py b/pylabrobot/legacy/heating_shaking/backend.py similarity index 61% rename from pylabrobot/heating_shaking/backend.py rename to pylabrobot/legacy/heating_shaking/backend.py index 861af2a1314..3c0ee8c44a8 100644 --- a/pylabrobot/heating_shaking/backend.py +++ b/pylabrobot/legacy/heating_shaking/backend.py @@ -1,5 +1,5 @@ -from pylabrobot.shaking.backend import ShakerBackend -from pylabrobot.temperature_controlling.backend import ( +from pylabrobot.legacy.shaking.backend import ShakerBackend +from pylabrobot.legacy.temperature_controlling.backend import ( TemperatureControllerBackend, ) diff --git a/pylabrobot/legacy/heating_shaking/bioshake_backend.py b/pylabrobot/legacy/heating_shaking/bioshake_backend.py new file mode 100644 index 00000000000..911f947e846 --- /dev/null +++ b/pylabrobot/legacy/heating_shaking/bioshake_backend.py @@ -0,0 +1,58 @@ +"""Legacy. Use pylabrobot.qinstruments.BioShakeBackend instead.""" + +from pylabrobot.legacy.heating_shaking.backend import HeaterShakerBackend +from pylabrobot.qinstruments import bioshake + + +class BioShake(HeaterShakerBackend): + """Legacy. Use pylabrobot.qinstruments.BioShakeBackend instead.""" + + def __init__(self, port: str, timeout: int = 60): + self._new = bioshake.BioShakeBackend(port=port, timeout=timeout) + + @property + def supports_active_cooling(self) -> bool: + return self._new.supports_active_cooling + + @property + def supports_locking(self) -> bool: + return self._new.supports_locking + + async def setup(self, skip_home: bool = False): + await self._new.setup(skip_home=skip_home) + + async def stop(self): + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def reset(self): + await self._new.reset() + + async def home(self): + await self._new.home() + + async def start_shaking(self, speed: float, acceleration: int = 0): + await self._new.start_shaking(speed=speed, acceleration=acceleration) + + async def shake(self, speed: float, acceleration: int = 0): + await self._new.start_shaking(speed=speed, acceleration=acceleration) + + async def stop_shaking(self, deceleration: int = 0): + await self._new.stop_shaking(deceleration=deceleration) + + async def lock_plate(self): + await self._new.lock_plate() + + async def unlock_plate(self): + await self._new.unlock_plate() + + async def set_temperature(self, temperature: float): + await self._new.set_temperature(temperature) + + async def get_current_temperature(self) -> float: + return await self._new.get_current_temperature() + + async def deactivate(self): + await self._new.deactivate() diff --git a/pylabrobot/legacy/heating_shaking/chatterbox.py b/pylabrobot/legacy/heating_shaking/chatterbox.py new file mode 100644 index 00000000000..2a7975f9fe0 --- /dev/null +++ b/pylabrobot/legacy/heating_shaking/chatterbox.py @@ -0,0 +1,9 @@ +from pylabrobot.legacy.heating_shaking import HeaterShakerBackend +from pylabrobot.legacy.shaking import ShakerChatterboxBackend +from pylabrobot.legacy.temperature_controlling import TemperatureControllerChatterboxBackend + + +class HeaterShakerChatterboxBackend( + HeaterShakerBackend, ShakerChatterboxBackend, TemperatureControllerChatterboxBackend +): + pass diff --git a/pylabrobot/legacy/heating_shaking/hamilton_backend.py b/pylabrobot/legacy/heating_shaking/hamilton_backend.py new file mode 100644 index 00000000000..d26650a800b --- /dev/null +++ b/pylabrobot/legacy/heating_shaking/hamilton_backend.py @@ -0,0 +1,89 @@ +"""Legacy. Use pylabrobot.hamilton.heater_shaker instead.""" + +import warnings +from typing import Dict, Literal, Optional + +from pylabrobot.hamilton.heater_shaker import backend as hhs_backend +from pylabrobot.hamilton.heater_shaker import box +from pylabrobot.legacy.heating_shaking.backend import HeaterShakerBackend + +HamiltonHeaterShakerInterface = box.HamiltonHeaterShakerInterface +HamiltonHeaterShakerBox = box.HamiltonHeaterShakerBox + + +class HamiltonHeaterShakerBackend(HeaterShakerBackend): + """Legacy. Use pylabrobot.hamilton.heater_shaker.HamiltonHeaterShakerBackend instead.""" + + def __init__(self, index: int, interface: HamiltonHeaterShakerInterface) -> None: + self._new = hhs_backend.HamiltonHeaterShakerBackend(index=index, interface=interface) + + @property + def supports_active_cooling(self) -> bool: + return self._new.supports_active_cooling + + @property + def supports_locking(self) -> bool: + return self._new.supports_locking + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def start_shaking( + self, + speed: float = 800, + direction: Literal[0, 1] = 0, + acceleration: int = 1_000, + timeout: Optional[float] = 30, + ): + await self._new.start_shaking( + speed=speed, direction=direction, acceleration=acceleration, timeout=timeout + ) + + async def shake( + self, + speed: float = 800, + direction: Literal[0, 1] = 0, + acceleration: int = 1_000, + timeout: Optional[float] = 30, + ): + warnings.warn( + "HamiltonHeaterShakerBackend.shake() is deprecated. Use start_shaking() instead.", + DeprecationWarning, + stacklevel=2, + ) + await self.start_shaking( + speed=speed, direction=direction, acceleration=acceleration, timeout=timeout + ) + + async def stop_shaking(self): + await self._new.stop_shaking() + + async def get_is_shaking(self) -> bool: + return await self._new.get_is_shaking() + + async def lock_plate(self): + await self._new.lock_plate() + + async def unlock_plate(self): + await self._new.unlock_plate() + + async def set_temperature(self, temperature: float): + await self._new.set_temperature(temperature=temperature) + + async def get_current_temperature(self) -> float: + return await self._new.get_current_temperature() + + async def _get_current_temperature(self) -> Dict[str, float]: + return await self._new._get_current_temperature() + + async def get_edge_temperature(self) -> float: + return await self._new.get_edge_temperature() + + async def deactivate(self): + await self._new.deactivate() diff --git a/pylabrobot/heating_shaking/heater_shaker.py b/pylabrobot/legacy/heating_shaking/heater_shaker.py similarity index 82% rename from pylabrobot/heating_shaking/heater_shaker.py rename to pylabrobot/legacy/heating_shaking/heater_shaker.py index 39437de1ee7..de35bb94869 100644 --- a/pylabrobot/heating_shaking/heater_shaker.py +++ b/pylabrobot/legacy/heating_shaking/heater_shaker.py @@ -1,9 +1,9 @@ from typing import Optional -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.legacy.shaking import Shaker +from pylabrobot.legacy.temperature_controlling import TemperatureController from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.shaking import Shaker -from pylabrobot.temperature_controlling import TemperatureController from .backend import HeaterShakerBackend diff --git a/pylabrobot/heating_shaking/heater_shaker_tests.py b/pylabrobot/legacy/heating_shaking/heater_shaker_tests.py similarity index 83% rename from pylabrobot/heating_shaking/heater_shaker_tests.py rename to pylabrobot/legacy/heating_shaking/heater_shaker_tests.py index d796e1fc1eb..841ab451802 100644 --- a/pylabrobot/heating_shaking/heater_shaker_tests.py +++ b/pylabrobot/legacy/heating_shaking/heater_shaker_tests.py @@ -1,6 +1,6 @@ import unittest -from pylabrobot.heating_shaking import HeaterShaker, HeaterShakerChatterboxBackend +from pylabrobot.legacy.heating_shaking import HeaterShaker, HeaterShakerChatterboxBackend from pylabrobot.resources.coordinate import Coordinate diff --git a/pylabrobot/heating_shaking/inheco/__init__.py b/pylabrobot/legacy/heating_shaking/inheco/__init__.py similarity index 100% rename from pylabrobot/heating_shaking/inheco/__init__.py rename to pylabrobot/legacy/heating_shaking/inheco/__init__.py diff --git a/pylabrobot/heating_shaking/inheco/thermoshake.py b/pylabrobot/legacy/heating_shaking/inheco/thermoshake.py similarity index 86% rename from pylabrobot/heating_shaking/inheco/thermoshake.py rename to pylabrobot/legacy/heating_shaking/inheco/thermoshake.py index fbafa036d9d..52ffd22519d 100644 --- a/pylabrobot/heating_shaking/inheco/thermoshake.py +++ b/pylabrobot/legacy/heating_shaking/inheco/thermoshake.py @@ -1,7 +1,7 @@ -from pylabrobot.heating_shaking.heater_shaker import HeaterShaker -from pylabrobot.heating_shaking.inheco.thermoshake_backend import InhecoThermoshakeBackend +from pylabrobot.legacy.heating_shaking.heater_shaker import HeaterShaker +from pylabrobot.legacy.heating_shaking.inheco.thermoshake_backend import InhecoThermoshakeBackend +from pylabrobot.legacy.temperature_controlling.inheco.control_box import InhecoTECControlBox from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.temperature_controlling.inheco.control_box import InhecoTECControlBox def inheco_thermoshake_ac(name: str, control_box: InhecoTECControlBox, index: int) -> HeaterShaker: diff --git a/pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py b/pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py new file mode 100644 index 00000000000..96355044022 --- /dev/null +++ b/pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py @@ -0,0 +1,78 @@ +"""Legacy. Use pylabrobot.inheco.thermoshake.InhecoThermoshakeBackend instead.""" + +from pylabrobot.inheco import thermoshake +from pylabrobot.legacy.heating_shaking.backend import HeaterShakerBackend + + +class InhecoThermoshakeBackend(HeaterShakerBackend): + """Legacy. Use pylabrobot.inheco.InhecoThermoshakeBackend instead.""" + + def __init__(self, index: int, control_box): + self._new = thermoshake.InhecoThermoshakeBackend(index=index, control_box=control_box) + + @property + def index(self) -> int: + return self._new.index + + @property + def interface(self): + return self._new.interface + + @property + def supports_active_cooling(self) -> bool: + return self._new.supports_active_cooling + + @property + def supports_locking(self) -> bool: + return self._new.supports_locking + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def set_temperature(self, temperature: float): + await self._new.set_temperature(temperature) + + async def get_current_temperature(self) -> float: + return await self._new.get_current_temperature() + + async def deactivate(self): + await self._new.deactivate() + + async def set_target_temperature(self, temperature: float): + await self._new.set_target_temperature(temperature) + + async def start_temperature_control(self): + return await self._new.start_temperature_control() + + async def stop_temperature_control(self): + return await self._new.stop_temperature_control() + + async def get_device_info(self, info_type: int): + return await self._new.get_device_info(info_type) + + async def start_shaking(self, speed: float, shape: int = 0): + await self._new.start_shaking(speed=speed, shape=shape) + + async def stop_shaking(self): + return await self._new.stop_shaking() + + async def set_shaker_speed(self, speed: float): + return await self._new.set_shaker_speed(speed) + + async def set_shaker_shape(self, shape: int): + return await self._new.set_shaker_shape(shape) + + async def shake(self, speed: float, shape: int = 0): + await self._new.shake(speed=speed, shape=shape) + + async def lock_plate(self): + await self._new.lock_plate() + + async def unlock_plate(self): + await self._new.unlock_plate() diff --git a/pylabrobot/liquid_handling/__init__.py b/pylabrobot/legacy/liquid_handling/__init__.py similarity index 100% rename from pylabrobot/liquid_handling/__init__.py rename to pylabrobot/legacy/liquid_handling/__init__.py diff --git a/pylabrobot/liquid_handling/backends/__init__.py b/pylabrobot/legacy/liquid_handling/backends/__init__.py similarity index 100% rename from pylabrobot/liquid_handling/backends/__init__.py rename to pylabrobot/legacy/liquid_handling/backends/__init__.py diff --git a/pylabrobot/liquid_handling/backends/backend.py b/pylabrobot/legacy/liquid_handling/backends/backend.py similarity index 97% rename from pylabrobot/liquid_handling/backends/backend.py rename to pylabrobot/legacy/liquid_handling/backends/backend.py index dc3253986e3..9219dce0241 100644 --- a/pylabrobot/liquid_handling/backends/backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/backend.py @@ -3,7 +3,7 @@ from abc import ABCMeta, abstractmethod from typing import Dict, List, Optional, Union -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, MultiHeadAspirationContainer, @@ -18,7 +18,7 @@ SingleChannelAspiration, SingleChannelDispense, ) -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.resources import Deck, Tip from pylabrobot.resources.tip_tracker import TipTracker diff --git a/pylabrobot/liquid_handling/backends/chatterbox.py b/pylabrobot/legacy/liquid_handling/backends/chatterbox.py similarity index 98% rename from pylabrobot/liquid_handling/backends/chatterbox.py rename to pylabrobot/legacy/liquid_handling/backends/chatterbox.py index 227803bc860..e147601de21 100644 --- a/pylabrobot/liquid_handling/backends/chatterbox.py +++ b/pylabrobot/legacy/liquid_handling/backends/chatterbox.py @@ -1,9 +1,9 @@ from typing import List, Optional, Union -from pylabrobot.liquid_handling.backends.backend import ( +from pylabrobot.legacy.liquid_handling.backends.backend import ( LiquidHandlerBackend, ) -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, MultiHeadAspirationContainer, diff --git a/pylabrobot/liquid_handling/backends/chatterbox_backend.py b/pylabrobot/legacy/liquid_handling/backends/chatterbox_backend.py similarity index 100% rename from pylabrobot/liquid_handling/backends/chatterbox_backend.py rename to pylabrobot/legacy/liquid_handling/backends/chatterbox_backend.py diff --git a/pylabrobot/liquid_handling/backends/chatterbox_tests.py b/pylabrobot/legacy/liquid_handling/backends/chatterbox_tests.py similarity index 94% rename from pylabrobot/liquid_handling/backends/chatterbox_tests.py rename to pylabrobot/legacy/liquid_handling/backends/chatterbox_tests.py index 02e8642d51b..da0517a0649 100644 --- a/pylabrobot/liquid_handling/backends/chatterbox_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/chatterbox_tests.py @@ -1,7 +1,7 @@ import unittest -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.backends.chatterbox import ( +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.backends.chatterbox import ( LiquidHandlerChatterboxBackend, ) from pylabrobot.resources import ( diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py index 553a27608fb..3e479f03f9a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py @@ -34,18 +34,18 @@ from typing import Concatenate, ParamSpec from pylabrobot import audio -from pylabrobot.heating_shaking.hamilton_backend import HamiltonHeaterShakerInterface -from pylabrobot.liquid_handling.backends.hamilton.base import ( +from pylabrobot.legacy.heating_shaking.hamilton_backend import HamiltonHeaterShakerInterface +from pylabrobot.legacy.liquid_handling.backends.hamilton.base import ( HamiltonLiquidHandler, ) -from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults -from pylabrobot.liquid_handling.backends.hamilton.planning import group_by_x_batch_by_xy -from pylabrobot.liquid_handling.errors import ChannelizedError -from pylabrobot.liquid_handling.liquid_classes.hamilton import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.common import fill_in_defaults +from pylabrobot.legacy.liquid_handling.backends.hamilton.planning import group_by_x_batch_by_xy +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton import ( HamiltonLiquidClass, get_star_liquid_class, ) -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, GripDirection, @@ -62,7 +62,7 @@ SingleChannelAspiration, SingleChannelDispense, ) -from pylabrobot.liquid_handling.utils import ( +from pylabrobot.legacy.liquid_handling.utils import ( MIN_SPACING_EDGE, get_tight_single_resource_liquid_op_offsets, get_wide_single_resource_liquid_op_offsets, diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_chatterbox.py similarity index 98% rename from pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_chatterbox.py index fc642de8b33..adc58a6d10e 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_chatterbox.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_chatterbox.py @@ -4,8 +4,8 @@ from contextlib import asynccontextmanager from typing import Dict, List, Literal, Optional, Union -from pylabrobot.liquid_handling.backends import LiquidHandlerBackend -from pylabrobot.liquid_handling.backends.hamilton.STAR_backend import ( +from pylabrobot.legacy.liquid_handling.backends import LiquidHandlerBackend +from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import ( DriveConfiguration, ExtendedConfiguration, Head96Information, diff --git a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py index b478ff7b637..5fc9a77e5a6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/STAR_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_tests.py @@ -4,10 +4,10 @@ import unittest.mock from typing import Literal, cast -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.standard import GripDirection, Pickup -from pylabrobot.plate_reading import PlateReader -from pylabrobot.plate_reading.chatterbox import PlateReaderChatterboxBackend +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.standard import GripDirection, Pickup +from pylabrobot.legacy.plate_reading import PlateReader +from pylabrobot.legacy.plate_reading.chatterbox import PlateReaderChatterboxBackend from pylabrobot.resources import ( PLT_CAR_L5AC_A00, PLT_CAR_L5MD_A00, diff --git a/pylabrobot/liquid_handling/backends/hamilton/__init__.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/__init__.py similarity index 100% rename from pylabrobot/liquid_handling/backends/hamilton/__init__.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/__init__.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/base.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/base.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/base.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/base.py index 73ff83be6f7..a44666407fd 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/base.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/base.py @@ -16,10 +16,10 @@ ) from pylabrobot.io.usb import USB -from pylabrobot.liquid_handling.backends.backend import ( +from pylabrobot.legacy.liquid_handling.backends.backend import ( LiquidHandlerBackend, ) -from pylabrobot.liquid_handling.standard import PipettingOp +from pylabrobot.legacy.liquid_handling.standard import PipettingOp from pylabrobot.resources import TipSpot from pylabrobot.resources.hamilton import ( HamiltonTip, diff --git a/pylabrobot/liquid_handling/backends/hamilton/common.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/common.py similarity index 100% rename from pylabrobot/liquid_handling/backends/hamilton/common.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/common.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend.py index 7018e22590b..8536e33d939 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend.py @@ -10,21 +10,21 @@ import logging from typing import Dict, List, Optional, Sequence, Tuple, TypeVar, Union -from pylabrobot.liquid_handling.backends.hamilton.common import fill_in_defaults -from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.introspection import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.common import fill_in_defaults +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.introspection import ( HamiltonIntrospection, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( HoiParams, HoiParamsParser, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( HamiltonProtocol, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPBackend -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp_backend import HamiltonTCPBackend +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, MultiHeadAspirationContainer, diff --git a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py similarity index 98% rename from pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py index 75c71692f91..5da385713df 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/nimbus_backend_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/nimbus_backend_tests.py @@ -8,7 +8,7 @@ import unittest.mock from typing import Optional -from pylabrobot.liquid_handling.backends.hamilton.nimbus_backend import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.nimbus_backend import ( Aspirate, DisableADC, Dispense, @@ -31,10 +31,13 @@ UnlockDoor, _get_tip_type_from_tip, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams, HoiParamsParser -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( + HoiParams, + HoiParamsParser, +) +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.legacy.liquid_handling.standard import ( Drop, Pickup, SingleChannelAspiration, diff --git a/pylabrobot/liquid_handling/backends/hamilton/planning.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/planning.py similarity index 97% rename from pylabrobot/liquid_handling/backends/hamilton/planning.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/planning.py index c050114bfc6..7e4b0495d1b 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/planning.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/planning.py @@ -1,6 +1,6 @@ from typing import Callable, Dict, List -from pylabrobot.liquid_handling.utils import MIN_SPACING_BETWEEN_CHANNELS +from pylabrobot.legacy.liquid_handling.utils import MIN_SPACING_BETWEEN_CHANNELS from pylabrobot.resources import Coordinate diff --git a/pylabrobot/liquid_handling/backends/hamilton/planning_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/planning_tests.py similarity index 100% rename from pylabrobot/liquid_handling/backends/hamilton/planning_tests.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/planning_tests.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/pump.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/pump.py similarity index 93% rename from pylabrobot/liquid_handling/backends/hamilton/pump.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/pump.py index 615da53efae..3fd04ba64e4 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/pump.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/pump.py @@ -1,4 +1,4 @@ -from pylabrobot.liquid_handling.backends.hamilton.STAR_backend import STAR +from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import STAR from pylabrobot.resources import Coordinate, Resource diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/__init__.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/__init__.py similarity index 100% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/__init__.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/__init__.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py similarity index 95% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py index 633a6b15c01..89dc55894e6 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/commands.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/commands.py @@ -9,13 +9,13 @@ import inspect from typing import Optional -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( CommandMessage, CommandResponse, HoiParams, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import HamiltonProtocol class HamiltonCommand: diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py similarity index 98% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py index 247de40fde1..5e19c55d7a2 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/introspection.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/introspection.py @@ -11,10 +11,13 @@ from dataclasses import dataclass, field from typing import Any, Dict, List -from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import HoiParams, HoiParamsParser -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( + HoiParams, + HoiParamsParser, +) +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( HamiltonDataType, HamiltonProtocol, ) diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py index df32f5289ab..d2f6bb98729 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/messages.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/messages.py @@ -38,14 +38,14 @@ from typing import Any from pylabrobot.io.binary import Reader, Writer -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import ( Address, HarpPacket, HoiPacket, IpPacket, RegistrationPacket, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( HamiltonDataType, HarpTransportableProtocol, RegistrationOptionType, diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/packets.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/packets.py similarity index 100% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/packets.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/packets.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/protocol.py similarity index 100% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/protocol.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/protocol.py diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/tcp_tests.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/tcp_tests.py index 80c249a424a..ca670469dc3 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp/tcp_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp/tcp_tests.py @@ -6,8 +6,8 @@ import unittest -from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( CommandMessage, CommandResponse, HoiParams, @@ -17,7 +17,7 @@ RegistrationMessage, RegistrationResponse, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import ( Address, HarpPacket, HoiPacket, @@ -26,7 +26,7 @@ decode_version_byte, encode_version_byte, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( HamiltonDataType, HamiltonProtocol, Hoi2Action, diff --git a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp_backend.py similarity index 97% rename from pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/tcp_backend.py index 9c6a9acbb13..3792732dd5a 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/tcp_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/tcp_backend.py @@ -13,17 +13,17 @@ from pylabrobot.io.binary import Reader from pylabrobot.io.socket import Socket -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend -from pylabrobot.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand -from pylabrobot.liquid_handling.backends.hamilton.tcp.messages import ( +from pylabrobot.legacy.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.commands import HamiltonCommand +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.messages import ( CommandResponse, InitMessage, InitResponse, RegistrationMessage, RegistrationResponse, ) -from pylabrobot.liquid_handling.backends.hamilton.tcp.packets import Address -from pylabrobot.liquid_handling.backends.hamilton.tcp.protocol import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.packets import Address +from pylabrobot.legacy.liquid_handling.backends.hamilton.tcp.protocol import ( Hoi2Action, HoiRequestId, RegistrationActionCode, diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py index a68b8375790..07c02e4fc0c 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_backend.py @@ -5,14 +5,14 @@ import warnings from typing import Dict, List, Optional, Sequence, Union, cast -from pylabrobot.liquid_handling.backends.hamilton.base import ( +from pylabrobot.legacy.liquid_handling.backends.hamilton.base import ( HamiltonLiquidHandler, ) -from pylabrobot.liquid_handling.liquid_classes.hamilton import ( +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton import ( HamiltonLiquidClass, get_vantage_liquid_class, ) -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, MultiHeadAspirationContainer, diff --git a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py similarity index 99% rename from pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py rename to pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py index b7c95621fc0..3ca80f942bb 100644 --- a/pylabrobot/liquid_handling/backends/hamilton/vantage_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/vantage_tests.py @@ -1,8 +1,8 @@ import unittest from typing import Any, List, Optional -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.standard import Pickup +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.standard import Pickup from pylabrobot.resources import ( PLT_CAR_L5AC_A00, TIP_CAR_480_A00, diff --git a/pylabrobot/liquid_handling/backends/opentrons_backend.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py similarity index 99% rename from pylabrobot/liquid_handling/backends/opentrons_backend.py rename to pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py index f5e30322a9e..e99642e860e 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend.py @@ -2,11 +2,11 @@ from typing import Dict, List, Optional, Tuple, Union, cast from pylabrobot import utils -from pylabrobot.liquid_handling.backends.backend import ( +from pylabrobot.legacy.liquid_handling.backends.backend import ( LiquidHandlerBackend, ) -from pylabrobot.liquid_handling.errors import NoChannelError -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.errors import NoChannelError +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, MultiHeadAspirationContainer, diff --git a/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend_tests.py similarity index 97% rename from pylabrobot/liquid_handling/backends/opentrons_backend_tests.py rename to pylabrobot/legacy/liquid_handling/backends/opentrons_backend_tests.py index 05ea8e2845f..a724c02d996 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_backend_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_backend_tests.py @@ -5,12 +5,12 @@ pytest.importorskip("ot_api") -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.backends.opentrons_backend import ( +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.backends.opentrons_backend import ( OpentronsOT2Backend, ) -from pylabrobot.liquid_handling.errors import NoChannelError -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.errors import NoChannelError +from pylabrobot.legacy.liquid_handling.standard import ( Drop, Pickup, SingleChannelAspiration, diff --git a/pylabrobot/liquid_handling/backends/opentrons_simulator.py b/pylabrobot/legacy/liquid_handling/backends/opentrons_simulator.py similarity index 95% rename from pylabrobot/liquid_handling/backends/opentrons_simulator.py rename to pylabrobot/legacy/liquid_handling/backends/opentrons_simulator.py index 914ba848ea3..1008a7b75f2 100644 --- a/pylabrobot/liquid_handling/backends/opentrons_simulator.py +++ b/pylabrobot/legacy/liquid_handling/backends/opentrons_simulator.py @@ -7,9 +7,9 @@ import logging from typing import Dict, List, Optional, Tuple -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend -from pylabrobot.liquid_handling.backends.opentrons_backend import OpentronsOT2Backend -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.legacy.liquid_handling.backends.opentrons_backend import OpentronsOT2Backend +from pylabrobot.legacy.liquid_handling.standard import ( Drop, Pickup, SingleChannelAspiration, diff --git a/pylabrobot/liquid_handling/backends/serializing_backend.py b/pylabrobot/legacy/liquid_handling/backends/serializing_backend.py similarity index 98% rename from pylabrobot/liquid_handling/backends/serializing_backend.py rename to pylabrobot/legacy/liquid_handling/backends/serializing_backend.py index a227f3b9d43..69f3d93656f 100644 --- a/pylabrobot/liquid_handling/backends/serializing_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/serializing_backend.py @@ -1,10 +1,10 @@ from abc import ABCMeta, abstractmethod from typing import Any, Dict, List, Optional, Union, cast -from pylabrobot.liquid_handling.backends.backend import ( +from pylabrobot.legacy.liquid_handling.backends.backend import ( LiquidHandlerBackend, ) -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, MultiHeadAspirationContainer, diff --git a/pylabrobot/liquid_handling/backends/serializing_backend_tests.py b/pylabrobot/legacy/liquid_handling/backends/serializing_backend_tests.py similarity index 98% rename from pylabrobot/liquid_handling/backends/serializing_backend_tests.py rename to pylabrobot/legacy/liquid_handling/backends/serializing_backend_tests.py index b15dd9fd589..f77a2c994f3 100644 --- a/pylabrobot/liquid_handling/backends/serializing_backend_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/serializing_backend_tests.py @@ -1,8 +1,8 @@ import unittest from unittest.mock import AsyncMock -from pylabrobot.liquid_handling import LiquidHandler -from pylabrobot.liquid_handling.backends.serializing_backend import ( +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.backends.serializing_backend import ( SerializingBackend, ) from pylabrobot.resources import ( diff --git a/pylabrobot/liquid_handling/backends/tecan/EVO_backend.py b/pylabrobot/legacy/liquid_handling/backends/tecan/EVO_backend.py similarity index 99% rename from pylabrobot/liquid_handling/backends/tecan/EVO_backend.py rename to pylabrobot/legacy/liquid_handling/backends/tecan/EVO_backend.py index c4d9d2f2a9c..afb463bda95 100644 --- a/pylabrobot/liquid_handling/backends/tecan/EVO_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/tecan/EVO_backend.py @@ -11,18 +11,18 @@ ) from pylabrobot.io.usb import USB -from pylabrobot.liquid_handling.backends.backend import ( +from pylabrobot.legacy.liquid_handling.backends.backend import ( LiquidHandlerBackend, ) -from pylabrobot.liquid_handling.backends.tecan.errors import ( +from pylabrobot.legacy.liquid_handling.backends.tecan.errors import ( TecanError, error_code_to_exception, ) -from pylabrobot.liquid_handling.liquid_classes.tecan import ( +from pylabrobot.legacy.liquid_handling.liquid_classes.tecan import ( TecanLiquidClass, get_liquid_class, ) -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.standard import ( Drop, DropTipRack, MultiHeadAspirationContainer, diff --git a/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py b/pylabrobot/legacy/liquid_handling/backends/tecan/EVO_tests.py similarity index 99% rename from pylabrobot/liquid_handling/backends/tecan/EVO_tests.py rename to pylabrobot/legacy/liquid_handling/backends/tecan/EVO_tests.py index 723aebcff44..8819b7b7c3f 100644 --- a/pylabrobot/liquid_handling/backends/tecan/EVO_tests.py +++ b/pylabrobot/legacy/liquid_handling/backends/tecan/EVO_tests.py @@ -2,12 +2,12 @@ import unittest.mock from unittest.mock import call -from pylabrobot.liquid_handling.backends.tecan.EVO_backend import ( +from pylabrobot.legacy.liquid_handling.backends.tecan.EVO_backend import ( EVOBackend, LiHa, RoMa, ) -from pylabrobot.liquid_handling.standard import ( +from pylabrobot.legacy.liquid_handling.standard import ( GripDirection, Pickup, ResourceDrop, diff --git a/pylabrobot/liquid_handling/backends/tecan/__init__.py b/pylabrobot/legacy/liquid_handling/backends/tecan/__init__.py similarity index 100% rename from pylabrobot/liquid_handling/backends/tecan/__init__.py rename to pylabrobot/legacy/liquid_handling/backends/tecan/__init__.py diff --git a/pylabrobot/liquid_handling/backends/tecan/errors.py b/pylabrobot/legacy/liquid_handling/backends/tecan/errors.py similarity index 100% rename from pylabrobot/liquid_handling/backends/tecan/errors.py rename to pylabrobot/legacy/liquid_handling/backends/tecan/errors.py diff --git a/pylabrobot/liquid_handling/errors.py b/pylabrobot/legacy/liquid_handling/errors.py similarity index 100% rename from pylabrobot/liquid_handling/errors.py rename to pylabrobot/legacy/liquid_handling/errors.py diff --git a/pylabrobot/legacy/liquid_handling/liquid_classes/__init__.py b/pylabrobot/legacy/liquid_handling/liquid_classes/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/liquid_handling/liquid_classes/hamilton/__init__.py b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/__init__.py similarity index 100% rename from pylabrobot/liquid_handling/liquid_classes/hamilton/__init__.py rename to pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/__init__.py diff --git a/pylabrobot/liquid_handling/liquid_classes/hamilton/base.py b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/base.py similarity index 100% rename from pylabrobot/liquid_handling/liquid_classes/hamilton/base.py rename to pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/base.py diff --git a/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/star.py similarity index 99% rename from pylabrobot/liquid_handling/liquid_classes/hamilton/star.py rename to pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/star.py index 81fd3dee5fe..314774fc758 100644 --- a/pylabrobot/liquid_handling/liquid_classes/hamilton/star.py +++ b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/star.py @@ -1,6 +1,6 @@ from typing import Dict, Optional, Tuple -from pylabrobot.liquid_handling.liquid_classes.hamilton.base import ( +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import ( HamiltonLiquidClass, ) from pylabrobot.resources.liquid import Liquid diff --git a/pylabrobot/liquid_handling/liquid_classes/hamilton/vantage.py b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/vantage.py similarity index 99% rename from pylabrobot/liquid_handling/liquid_classes/hamilton/vantage.py rename to pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/vantage.py index 87bf132453f..b136491a660 100644 --- a/pylabrobot/liquid_handling/liquid_classes/hamilton/vantage.py +++ b/pylabrobot/legacy/liquid_handling/liquid_classes/hamilton/vantage.py @@ -1,6 +1,6 @@ from typing import Dict, Optional, Tuple -from pylabrobot.liquid_handling.liquid_classes.hamilton.base import ( +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton.base import ( HamiltonLiquidClass, ) from pylabrobot.resources.liquid import Liquid diff --git a/pylabrobot/liquid_handling/liquid_classes/tecan.py b/pylabrobot/legacy/liquid_handling/liquid_classes/tecan.py similarity index 100% rename from pylabrobot/liquid_handling/liquid_classes/tecan.py rename to pylabrobot/legacy/liquid_handling/liquid_classes/tecan.py diff --git a/pylabrobot/liquid_handling/liquid_handler.py b/pylabrobot/legacy/liquid_handling/liquid_handler.py similarity index 99% rename from pylabrobot/liquid_handling/liquid_handler.py rename to pylabrobot/legacy/liquid_handling/liquid_handler.py index 60eb2e8f11a..3159a828312 100644 --- a/pylabrobot/liquid_handling/liquid_handler.py +++ b/pylabrobot/legacy/liquid_handling/liquid_handler.py @@ -24,17 +24,18 @@ cast, ) -from pylabrobot.liquid_handling.errors import ChannelizedError -from pylabrobot.liquid_handling.strictness import ( +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError +from pylabrobot.legacy.liquid_handling.strictness import ( Strictness, get_strictness, ) -from pylabrobot.liquid_handling.utils import ( +from pylabrobot.legacy.liquid_handling.utils import ( get_tight_single_resource_liquid_op_offsets, get_wide_single_resource_liquid_op_offsets, ) -from pylabrobot.machines.machine import Machine, need_setup_finished -from pylabrobot.plate_reading import PlateReader +from pylabrobot.legacy.machines.machine import Machine, need_setup_finished +from pylabrobot.legacy.plate_reading import PlateReader +from pylabrobot.legacy.tilting.tilter import Tilter from pylabrobot.resources import ( Container, Coordinate, @@ -58,7 +59,6 @@ from pylabrobot.resources.errors import HasTipError from pylabrobot.resources.rotation import Rotation from pylabrobot.serializer import deserialize, serialize -from pylabrobot.tilting.tilter import Tilter from .backends import LiquidHandlerBackend from .standard import ( diff --git a/pylabrobot/liquid_handling/liquid_handler_tests.py b/pylabrobot/legacy/liquid_handling/liquid_handler_tests.py similarity index 99% rename from pylabrobot/liquid_handling/liquid_handler_tests.py rename to pylabrobot/legacy/liquid_handling/liquid_handler_tests.py index d31cfed45d0..8a0651a7c8c 100644 --- a/pylabrobot/liquid_handling/liquid_handler_tests.py +++ b/pylabrobot/legacy/liquid_handling/liquid_handler_tests.py @@ -7,14 +7,14 @@ import pytest -from pylabrobot.liquid_handling.backends.backend import LiquidHandlerBackend -from pylabrobot.liquid_handling.backends.chatterbox import LiquidHandlerChatterboxBackend -from pylabrobot.liquid_handling.errors import ChannelizedError -from pylabrobot.liquid_handling.strictness import ( +from pylabrobot.legacy.liquid_handling.backends.backend import LiquidHandlerBackend +from pylabrobot.legacy.liquid_handling.backends.chatterbox import LiquidHandlerChatterboxBackend +from pylabrobot.legacy.liquid_handling.errors import ChannelizedError +from pylabrobot.legacy.liquid_handling.strictness import ( Strictness, set_strictness, ) -from pylabrobot.liquid_handling.utils import get_tight_single_resource_liquid_op_offsets +from pylabrobot.legacy.liquid_handling.utils import get_tight_single_resource_liquid_op_offsets from pylabrobot.resources import ( PLT_CAR_L5AC_A00, TIP_CAR_480_A00, diff --git a/pylabrobot/liquid_handling/standard.py b/pylabrobot/legacy/liquid_handling/standard.py similarity index 100% rename from pylabrobot/liquid_handling/standard.py rename to pylabrobot/legacy/liquid_handling/standard.py diff --git a/pylabrobot/liquid_handling/strictness.py b/pylabrobot/legacy/liquid_handling/strictness.py similarity index 100% rename from pylabrobot/liquid_handling/strictness.py rename to pylabrobot/legacy/liquid_handling/strictness.py diff --git a/pylabrobot/liquid_handling/utils.py b/pylabrobot/legacy/liquid_handling/utils.py similarity index 100% rename from pylabrobot/liquid_handling/utils.py rename to pylabrobot/legacy/liquid_handling/utils.py diff --git a/pylabrobot/machines/__init__.py b/pylabrobot/legacy/machines/__init__.py similarity index 58% rename from pylabrobot/machines/__init__.py rename to pylabrobot/legacy/machines/__init__.py index cbfbf4e4c50..d30dcd873cf 100644 --- a/pylabrobot/machines/__init__.py +++ b/pylabrobot/legacy/machines/__init__.py @@ -1 +1,2 @@ +from .backend import MachineBackend from .machine import Machine, need_setup_finished diff --git a/pylabrobot/machines/backend.py b/pylabrobot/legacy/machines/backend.py similarity index 100% rename from pylabrobot/machines/backend.py rename to pylabrobot/legacy/machines/backend.py diff --git a/pylabrobot/machines/machine.py b/pylabrobot/legacy/machines/machine.py similarity index 79% rename from pylabrobot/machines/machine.py rename to pylabrobot/legacy/machines/machine.py index d241d8809a1..692366dac6b 100644 --- a/pylabrobot/machines/machine.py +++ b/pylabrobot/legacy/machines/machine.py @@ -3,9 +3,9 @@ import functools import sys from abc import ABC -from typing import Any, Awaitable, Callable, TypeVar +from typing import Any, Awaitable, Callable, List, TypeVar -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.serializer import SerializableMixin if sys.version_info < (3, 10): @@ -42,15 +42,16 @@ class Machine(SerializableMixin, ABC): """Abstract base class for machine frontends.""" def __init__(self, backend: MachineBackend): - self.backend = backend + self._backend = backend self._setup_finished = False + self._capabilities: List[Any] = [] @property def setup_finished(self) -> bool: return self._setup_finished def serialize(self) -> dict: - return {"backend": self.backend.serialize()} + return {"backend": self._backend.serialize()} @classmethod def deserialize(cls, data: dict): @@ -61,12 +62,16 @@ def deserialize(cls, data: dict): return cls(**data_copy) async def setup(self, **backend_kwargs): - await self.backend.setup(**backend_kwargs) + await self._backend.setup(**backend_kwargs) + for cap in self._capabilities: + await cap._on_setup() self._setup_finished = True @need_setup_finished async def stop(self): - await self.backend.stop() + for cap in reversed(self._capabilities): + await cap._on_stop() + await self._backend.stop() self._setup_finished = False async def __aenter__(self): diff --git a/pylabrobot/machines/machine_tests.py b/pylabrobot/legacy/machines/machine_tests.py similarity index 87% rename from pylabrobot/machines/machine_tests.py rename to pylabrobot/legacy/machines/machine_tests.py index bdfbb2d506e..5c8dbbde2d2 100644 --- a/pylabrobot/machines/machine_tests.py +++ b/pylabrobot/legacy/machines/machine_tests.py @@ -1,7 +1,8 @@ import unittest import unittest.mock -from pylabrobot.machines.machine import Machine, MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend +from pylabrobot.legacy.machines.machine import Machine class TestMachine(unittest.TestCase): diff --git a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py new file mode 100644 index 00000000000..076d5f1be09 --- /dev/null +++ b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py @@ -0,0 +1,125 @@ +"""Legacy. Use pylabrobot.molecular_devices.PicoBackend instead.""" + +from typing import Dict, List, Optional + +from pylabrobot.capabilities.microscopy import ( + ImagingMode as NewImagingMode, +) +from pylabrobot.capabilities.microscopy import ( + ImagingResult as NewImagingResult, +) +from pylabrobot.capabilities.microscopy import ( + Objective as NewObjective, +) +from pylabrobot.legacy.plate_reading.backend import ImagerBackend +from pylabrobot.legacy.plate_reading.standard import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.molecular_devices.imageXpress.pico.backend import PicoBackend +from pylabrobot.resources.plate import Plate + + +def _legacy_to_new_imaging_mode(mode: ImagingMode) -> NewImagingMode: + return NewImagingMode[mode.name] + + +def _legacy_to_new_objective(obj: Objective) -> NewObjective: + return NewObjective[obj.name] + + +def _new_to_legacy_imaging_result(result: NewImagingResult) -> ImagingResult: + return ImagingResult( + images=result.images, + exposure_time=result.exposure_time, + focal_height=result.focal_height, + ) + + +class ExperimentalPicoBackend(ImagerBackend): + """Legacy. Use pylabrobot.molecular_devices.PicoBackend instead.""" + + def __init__( + self, + host: str, + port: int = 8091, + lock_timeout: int = 3600, + objectives: Optional[Dict[int, Objective]] = None, + filter_cubes: Optional[Dict[int, ImagingMode]] = None, + ): + super().__init__() + new_objectives = {pos: _legacy_to_new_objective(obj) for pos, obj in (objectives or {}).items()} + new_filter_cubes = { + pos: _legacy_to_new_imaging_mode(mode) for pos, mode in (filter_cubes or {}).items() + } + self._new = PicoBackend( + host=host, + port=port, + lock_timeout=lock_timeout, + objectives=new_objectives, + filter_cubes=new_filter_cubes, + ) + + @property + def door_open(self) -> bool: + return self._new.door_open + + async def setup(self) -> None: + await self._new.setup() + + async def stop(self) -> None: + await self._new.stop() + + async def get_configuration(self) -> dict: + return await self._new.get_configuration() + + async def open_door(self) -> None: + await self._new.open_door() + + async def close_door(self) -> None: + await self._new.close_door() + + async def enter_objective_maintenance(self, position: int) -> None: + await self._new.enter_objective_maintenance(position) + + async def exit_objective_maintenance(self) -> None: + await self._new.exit_objective_maintenance() + + async def get_available_objectives(self, position: int) -> List[dict]: + return await self._new.get_available_objectives(position) + + async def get_available_filter_cubes(self) -> List[dict]: + return await self._new.get_available_filter_cubes() + + async def change_objective(self, position: int, objective_id: str) -> None: + await self._new.change_objective(position, objective_id) + + async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: + await self._new.change_filter_cube(position, filter_cube_id) + + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + ) -> ImagingResult: + result = await self._new.capture( + row=row, + column=column, + mode=_legacy_to_new_imaging_mode(mode), + objective=_legacy_to_new_objective(objective), + exposure_time=exposure_time, + focal_height=focal_height, + gain=gain, + plate=plate, + ) + return _new_to_legacy_imaging_result(result) diff --git a/pylabrobot/microscopes/molecular_devices/pico/backend_tests.py b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py similarity index 98% rename from pylabrobot/microscopes/molecular_devices/pico/backend_tests.py rename to pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py index 8e8c123ea65..fe075cbc399 100644 --- a/pylabrobot/microscopes/molecular_devices/pico/backend_tests.py +++ b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py @@ -21,6 +21,7 @@ import numpy as np # type: ignore[import-not-found] +from pylabrobot.capabilities.microscopy import ImagingMode, Objective from pylabrobot.io.sila.grpc import ( decode_fields, get_field_bytes, @@ -28,7 +29,7 @@ sila_string, varint_field, ) -from pylabrobot.microscopes.molecular_devices.pico.backend import ( +from pylabrobot.molecular_devices.imageXpress.pico.backend import ( _FC_SVC, _HW_SVC, _INST_SVC, @@ -36,12 +37,11 @@ _LOCK_SVC, _OBJ_SVC, _SNAP_SVC, - ExperimentalPicoBackend, + PicoBackend, _decode_intermediate_response, _extract_image_buffer, _get_image_info, ) -from pylabrobot.plate_reading.standard import ImagingMode, Objective from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType @@ -180,9 +180,9 @@ def _make_backend( objectives=None, filter_cubes=None, lock_timeout=3600, -) -> Tuple[ExperimentalPicoBackend, _MockChannel]: +) -> Tuple[PicoBackend, _MockChannel]: """Create a PicoBackend with a mock channel, bypassing setup().""" - backend = ExperimentalPicoBackend( + backend = PicoBackend( host="127.0.0.1", port=8091, lock_timeout=lock_timeout, @@ -223,7 +223,7 @@ def _unwrap_sila_string(data: bytes) -> str: class TestSetup(unittest.IsolatedAsyncioTestCase): async def test_setup_sends_correct_sequence(self): """setup() with no objectives/filter_cubes: unlock stale, lock, query hardware.""" - backend = ExperimentalPicoBackend(host="127.0.0.1", lock_timeout=120) + backend = PicoBackend(host="127.0.0.1", lock_timeout=120) channel = _MockChannel() channel.set_response(f"/{_LOCK_SVC}/UnlockServer", b"") @@ -254,7 +254,7 @@ async def test_setup_sends_correct_sequence(self): async def test_setup_configures_objectives_and_filter_cubes(self): """When objectives/filter_cubes are specified, setup() calls ChangeHardware.""" - backend = ExperimentalPicoBackend( + backend = PicoBackend( host="127.0.0.1", objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.DAPI}, @@ -409,7 +409,7 @@ async def test_decodes_instrument_configuration(self): self.assertEqual(len(channel.calls), 1) self.assertEqual(channel.calls[0].path, f"/{_INST_SVC}/Get_InstrumentConfiguration") self.assertEqual(channel.calls[0].request, b"") - # Response unwrapped from SiLA String → JSON → InstrumentConfiguration key extracted + # Response unwrapped from SiLA String -> JSON -> InstrumentConfiguration key extracted self.assertEqual(result, config["InstrumentConfiguration"]) diff --git a/pylabrobot/only_fans/__init__.py b/pylabrobot/legacy/only_fans/__init__.py similarity index 100% rename from pylabrobot/only_fans/__init__.py rename to pylabrobot/legacy/only_fans/__init__.py diff --git a/pylabrobot/only_fans/backend.py b/pylabrobot/legacy/only_fans/backend.py similarity index 54% rename from pylabrobot/only_fans/backend.py rename to pylabrobot/legacy/only_fans/backend.py index 7be5c212785..87085e82f6e 100644 --- a/pylabrobot/only_fans/backend.py +++ b/pylabrobot/legacy/only_fans/backend.py @@ -1,23 +1,23 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class FanBackend(MachineBackend, metaclass=ABCMeta): - """Abstract base class for fan backends.""" + """Legacy. Use pylabrobot.capabilities.fan_control.FanBackend instead.""" @abstractmethod async def setup(self) -> None: - """Set up the fan. This should be called before any other methods.""" + """Set up the fan.""" @abstractmethod async def turn_on(self, intensity: int) -> None: - """Run the fan at intensity: integer percent between 0 and 100""" + """Run the fan at intensity: integer percent between 0 and 100.""" @abstractmethod async def turn_off(self) -> None: - """Stop the fan, but don't close the connection.""" + """Stop the fan.""" @abstractmethod async def stop(self) -> None: - """Close all connections to the fan and make sure setup() can be called again.""" + """Close all connections to the fan.""" diff --git a/pylabrobot/only_fans/chatterbox.py b/pylabrobot/legacy/only_fans/chatterbox.py similarity index 63% rename from pylabrobot/only_fans/chatterbox.py rename to pylabrobot/legacy/only_fans/chatterbox.py index 25104bb4822..38404154c08 100644 --- a/pylabrobot/only_fans/chatterbox.py +++ b/pylabrobot/legacy/only_fans/chatterbox.py @@ -1,8 +1,10 @@ -from pylabrobot.only_fans import FanBackend +"""Legacy. Use pylabrobot.hamilton.hepa_fan.HamiltonHepaFanChatterboxBackend instead.""" + +from pylabrobot.legacy.only_fans.backend import FanBackend class FanChatterboxBackend(FanBackend): - """Chatter box backend for device-free testing. Prints out all operations.""" + """Legacy chatterbox backend for device-free testing.""" async def setup(self) -> None: print("Setting up the fan.") diff --git a/pylabrobot/legacy/only_fans/fan.py b/pylabrobot/legacy/only_fans/fan.py new file mode 100644 index 00000000000..6a2dd4a1b5e --- /dev/null +++ b/pylabrobot/legacy/only_fans/fan.py @@ -0,0 +1,47 @@ +"""Legacy. Use pylabrobot.hamilton.only_fans.HamiltonHepaFan instead.""" + +from pylabrobot.capabilities.fan_control import FanBackend as _NewFanBackend +from pylabrobot.capabilities.fan_control import FanControlCapability +from pylabrobot.legacy.machines.machine import Machine + +from .backend import FanBackend + + +class _FanAdapter(_NewFanBackend): + def __init__(self, legacy: FanBackend): + self._legacy = legacy + + async def setup(self): + pass + + async def stop(self): + pass + + async def turn_on(self, intensity: int) -> None: + await self._legacy.turn_on(intensity) + + async def turn_off(self) -> None: + await self._legacy.turn_off() + + +class Fan(Machine): + """Legacy. Use a vendor-specific machine class instead.""" + + def __init__(self, backend: FanBackend): + super().__init__(backend=backend) + self._backend: FanBackend = backend + self._cap = FanControlCapability(backend=_FanAdapter(backend)) + + async def setup(self, **backend_kwargs): + await super().setup(**backend_kwargs) + await self._cap._on_setup() + + async def turn_on(self, intensity: int, duration=None): + await self._cap.turn_on(intensity=intensity, duration=duration) + + async def turn_off(self): + await self._cap.turn_off() + + async def stop(self): + await self._cap._on_stop() + await super().stop() diff --git a/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py b/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py new file mode 100644 index 00000000000..be22976eb8d --- /dev/null +++ b/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py @@ -0,0 +1,32 @@ +"""Legacy. Use pylabrobot.hamilton.hepa_fan.HamiltonHepaFanBackend instead.""" + +from pylabrobot.hamilton.only_fans import backend as hepa_fan_backend +from pylabrobot.legacy.only_fans.backend import FanBackend + + +class HamiltonHepaFanBackend(FanBackend): + """Legacy. Use pylabrobot.hamilton.hepa_fan.HamiltonHepaFanBackend instead.""" + + def __init__(self, device_id=None): + self._new = hepa_fan_backend.HamiltonHepaFanBackend(device_id=device_id) + + async def setup(self) -> None: + await self._new.setup() + + async def turn_on(self, intensity: int) -> None: + await self._new.turn_on(intensity=intensity) + + async def turn_off(self) -> None: + await self._new.turn_off() + + async def stop(self) -> None: + await self._new.stop() + + +class HamiltonHepaFan: + """Deprecated. Use HamiltonHepaFanBackend instead.""" + + def __init__(self, *args, **kwargs): + raise RuntimeError( + "`HamiltonHepaFan` is deprecated. Please use `HamiltonHepaFanBackend` instead." + ) diff --git a/pylabrobot/peeling/__init__.py b/pylabrobot/legacy/peeling/__init__.py similarity index 100% rename from pylabrobot/peeling/__init__.py rename to pylabrobot/legacy/peeling/__init__.py diff --git a/pylabrobot/peeling/backend.py b/pylabrobot/legacy/peeling/backend.py similarity index 66% rename from pylabrobot/peeling/backend.py rename to pylabrobot/legacy/peeling/backend.py index 6d375205e44..44fb2fd8486 100644 --- a/pylabrobot/peeling/backend.py +++ b/pylabrobot/legacy/peeling/backend.py @@ -1,10 +1,10 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class PeelerBackend(MachineBackend, metaclass=ABCMeta): - """Backend for a peeler machine""" + """Legacy. Use pylabrobot.capabilities.peeling.PeelerBackend instead.""" @abstractmethod async def peel(self): diff --git a/pylabrobot/legacy/peeling/peeler.py b/pylabrobot/legacy/peeling/peeler.py new file mode 100644 index 00000000000..ac84a2f2b70 --- /dev/null +++ b/pylabrobot/legacy/peeling/peeler.py @@ -0,0 +1,19 @@ +"""Legacy. Use pylabrobot.azenta.XPeel instead.""" + +from pylabrobot.legacy.machines import Machine + +from .backend import PeelerBackend + + +class Peeler(Machine): + """Legacy. Use pylabrobot.azenta.XPeel instead.""" + + def __init__(self, backend: PeelerBackend): + super().__init__(backend=backend) + self._backend: PeelerBackend = backend + + async def peel(self, **backend_kwargs): + return await self._backend.peel(**backend_kwargs) + + async def restart(self, **backend_kwargs): + return await self._backend.restart(**backend_kwargs) diff --git a/pylabrobot/legacy/peeling/xpeel.py b/pylabrobot/legacy/peeling/xpeel.py new file mode 100644 index 00000000000..1ddc8a851f8 --- /dev/null +++ b/pylabrobot/legacy/peeling/xpeel.py @@ -0,0 +1,11 @@ +"""Legacy. Use pylabrobot.azenta.XPeel instead.""" + +from pylabrobot.legacy.peeling.peeler import Peeler +from pylabrobot.legacy.peeling.xpeel_backend import XPeelBackend + + +def xpeel(port: str) -> Peeler: + """Legacy. Use pylabrobot.azenta.XPeel instead.""" + return Peeler( + backend=XPeelBackend(port=port), + ) diff --git a/pylabrobot/legacy/peeling/xpeel_backend.py b/pylabrobot/legacy/peeling/xpeel_backend.py new file mode 100644 index 00000000000..0445d9ddeb9 --- /dev/null +++ b/pylabrobot/legacy/peeling/xpeel_backend.py @@ -0,0 +1,68 @@ +"""Legacy. Use pylabrobot.azenta.XPeelBackend instead.""" + +from pylabrobot.azenta import xpeel +from pylabrobot.legacy.peeling.backend import PeelerBackend + + +class XPeelBackend(PeelerBackend): + """Legacy. Use pylabrobot.azenta.XPeelBackend instead.""" + + def __init__(self, port: str, timeout=None): + self._new = xpeel.XPeelBackend(port=port, timeout=timeout) + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def peel(self, **kwargs): + return await self._new.peel(**kwargs) + + async def restart(self): + return await self._new.restart() + + async def reset(self): + return await self._new.reset() + + async def get_status(self): + return await self._new.get_status() + + async def get_version(self): + return await self._new.get_version() + + async def seal_check(self): + return await self._new.seal_check() + + async def get_tape_remaining(self): + return await self._new.get_tape_remaining() + + async def enable_plate_check(self, enabled=True): + return await self._new.enable_plate_check(enabled=enabled) + + async def get_seal_sensor_status(self): + return await self._new.get_seal_sensor_status() + + async def set_seal_threshold_upper(self, value: int): + return await self._new.set_seal_threshold_upper(value=value) + + async def set_seal_threshold_lower(self, value: int): + return await self._new.set_seal_threshold_lower(value=value) + + async def move_conveyor_out(self): + return await self._new.move_conveyor_out() + + async def move_conveyor_in(self): + return await self._new.move_conveyor_in() + + async def move_elevator_down(self): + return await self._new.move_elevator_down() + + async def move_elevator_up(self): + return await self._new.move_elevator_up() + + async def advance_tape(self): + return await self._new.advance_tape() diff --git a/pylabrobot/plate_reading/__init__.py b/pylabrobot/legacy/plate_reading/__init__.py similarity index 96% rename from pylabrobot/plate_reading/__init__.py rename to pylabrobot/legacy/plate_reading/__init__.py index 7e8366c6a58..7802c4a33d2 100644 --- a/pylabrobot/plate_reading/__init__.py +++ b/pylabrobot/legacy/plate_reading/__init__.py @@ -4,8 +4,6 @@ from .agilent import ( BioTekPlateReaderBackend, - Cytation5Backend, - Cytation5ImagingConfig, CytationBackend, CytationImagingConfig, SynergyH1Backend, diff --git a/pylabrobot/legacy/plate_reading/agilent/__init__.py b/pylabrobot/legacy/plate_reading/agilent/__init__.py new file mode 100644 index 00000000000..700006e1e47 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/agilent/__init__.py @@ -0,0 +1,3 @@ +from .biotek_backend import BioTekPlateReaderBackend +from .biotek_cytation_backend import CytationBackend, CytationImagingConfig +from .biotek_synergyh1_backend import SynergyH1Backend diff --git a/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py b/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py new file mode 100644 index 00000000000..69120b5e2ba --- /dev/null +++ b/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py @@ -0,0 +1,205 @@ +"""Legacy. Use pylabrobot.agilent instead.""" + +from typing import Dict, List, Optional + +from pylabrobot.agilent.biotek import biotek +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources import Plate, Well + + +class BioTekPlateReaderBackend(PlateReaderBackend): + """Legacy. Use pylabrobot.agilent.BioTekBackend instead.""" + + def __init__( + self, + timeout: float = 20, + device_id: Optional[str] = None, + ) -> None: + self._new = biotek.BioTekBackend(timeout=timeout, device_id=device_id) + + # Expose internals for subclass compatibility + @property + def io(self): + return self._new.io + + @io.setter + def io(self, value): + self._new.io = value + + @property + def timeout(self): + return self._new.timeout + + @timeout.setter + def timeout(self, value): + self._new.timeout = value + + @property + def _plate(self): + return self._new._plate + + @_plate.setter + def _plate(self, value): + self._new._plate = value + + @property + def _shaking(self): + return self._new._shaking + + @_shaking.setter + def _shaking(self, value): + self._new._shaking = value + + @property + def _slow_mode(self): + return self._new._slow_mode + + @_slow_mode.setter + def _slow_mode(self, value): + self._new._slow_mode = value + + @property + def _version(self): + return self._new._version + + @_version.setter + def _version(self, value): + self._new._version = value + + async def setup(self) -> None: + await self._new.setup() + + async def stop(self) -> None: + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + @property + def version(self) -> str: + return self._new.version + + @property + def abs_wavelength_range(self): + return self._new.abs_wavelength_range + + @property + def focal_height_range(self): + return self._new.focal_height_range + + @property + def excitation_range(self): + return self._new.excitation_range + + @property + def emission_range(self): + return self._new.emission_range + + @property + def supports_heating(self) -> bool: + return self._new.supports_heating + + @property + def supports_cooling(self) -> bool: + return self._new.supports_cooling + + @property + def temperature_range(self): + return self._new.temperature_range + + async def send_command(self, command, parameter=None, wait_for_response=True, timeout=None): + return await self._new.send_command(command, parameter, wait_for_response, timeout) + + async def _read_until(self, terminator, timeout=None): + return await self._new._read_until(terminator, timeout) + + async def get_serial_number(self): + return await self._new.get_serial_number() + + async def get_firmware_version(self): + return await self._new.get_firmware_version() + + async def open(self, slow=False): + return await self._new.open(slow=slow) + + async def close(self, plate=None, slow=False): + return await self._new.close(plate=plate, slow=slow) + + async def home(self): + return await self._new.home() + + async def get_current_temperature(self): + return await self._new.get_current_temperature() + + async def set_temperature(self, temperature): + return await self._new.set_temperature(temperature) + + async def stop_heating_or_cooling(self): + return await self._new.stop_heating_or_cooling() + + def _parse_body(self, body): + return self._new._parse_body(body) + + async def set_plate(self, plate): + return await self._new.set_plate(plate) + + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: + results = await self._new.read_absorbance(plate=plate, wells=wells, wavelength=wavelength) + return [ + { + "wavelength": r.wavelength, + "data": r.data, + "temperature": r.temperature if r.temperature is not None else float("nan"), + "time": r.timestamp, + } + for r in results + ] + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 1 + ) -> List[Dict]: + results = await self._new.read_luminescence( + plate=plate, wells=wells, focal_height=focal_height, integration_time=integration_time + ) + return [ + { + "data": r.data, + "temperature": r.temperature if r.temperature is not None else float("nan"), + "time": r.timestamp, + } + for r in results + ] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict]: + results = await self._new.read_fluorescence( + plate=plate, + wells=wells, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + focal_height=focal_height, + ) + return [ + { + "ex_wavelength": r.excitation_wavelength, + "em_wavelength": r.emission_wavelength, + "data": r.data, + "temperature": r.temperature if r.temperature is not None else float("nan"), + "time": r.timestamp, + } + for r in results + ] + + ShakeType = biotek.BioTekBackend.ShakeType + + async def shake(self, shake_type, frequency): + return await self._new.shake(shake_type, frequency) + + async def stop_shaking(self): + return await self._new.stop_shaking() diff --git a/pylabrobot/legacy/plate_reading/agilent/biotek_cytation_backend.py b/pylabrobot/legacy/plate_reading/agilent/biotek_cytation_backend.py new file mode 100644 index 00000000000..445d279ad2f --- /dev/null +++ b/pylabrobot/legacy/plate_reading/agilent/biotek_cytation_backend.py @@ -0,0 +1,140 @@ +"""Legacy. Use pylabrobot.agilent instead.""" + +from typing import Optional + +from pylabrobot.agilent.biotek import cytation +from pylabrobot.agilent.biotek.cytation import CytationImagingConfig +from pylabrobot.legacy.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend +from pylabrobot.legacy.plate_reading.backend import ImagerBackend +from pylabrobot.legacy.plate_reading.standard import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + Objective, +) +from pylabrobot.resources import Plate + + +class CytationBackend(BioTekPlateReaderBackend, ImagerBackend): + """Legacy. Use pylabrobot.agilent.CytationBackend instead.""" + + _new: cytation.CytationBackend + + def __init__( + self, + timeout: float = 20, + device_id: Optional[str] = None, + imaging_config: Optional[CytationImagingConfig] = None, + ) -> None: + # Override _new with the Cytation backend (which extends BioTek) + self._new = cytation.CytationBackend( + timeout=timeout, device_id=device_id, imaging_config=imaging_config + ) + + @property + def imaging_config(self): + return self._new.imaging_config + + @imaging_config.setter + def imaging_config(self, value): + self._new.imaging_config = value + + async def setup(self, use_cam: bool = False) -> None: + await self._new.setup(use_cam=use_cam) + + async def stop(self): + await self._new.stop() + + @property + def supports_heating(self): + return True + + @property + def supports_cooling(self): + return True + + @property + def objectives(self): + return self._new.objectives + + @property + def filters(self): + return self._new.filters + + async def close(self, plate=None, slow=False): + await self._new.close(plate=plate, slow=slow) + + def start_acquisition(self): + self._new.start_acquisition() + + def stop_acquisition(self): + self._new.stop_acquisition() + + async def led_on(self, intensity=10): + await self._new.led_on(intensity=intensity) + + async def led_off(self): + await self._new.led_off() + + async def set_focus(self, focal_position): + await self._new.set_focus(focal_position) + + async def set_position(self, x, y): + await self._new.set_position(x, y) + + async def set_auto_exposure(self, auto_exposure): + await self._new.set_auto_exposure(auto_exposure) + + async def set_exposure(self, exposure): + await self._new.set_exposure(exposure) + + async def select(self, row, column): + await self._new.select(row, column) + + async def set_gain(self, gain): + await self._new.set_gain(gain) + + async def set_objective(self, objective): + await self._new.set_objective(objective) + + async def set_imaging_mode(self, mode, led_intensity): + await self._new.set_imaging_mode(mode, led_intensity) + + async def capture( + self, + row: int, + column: int, + mode: ImagingMode, + objective: Objective, + exposure_time: Exposure, + focal_height: FocalPosition, + gain: Gain, + plate: Plate, + **kwargs, + ) -> ImagingResult: + # Map legacy ImagingMode to new ImagingMode + from pylabrobot.capabilities.microscopy.standard import ImagingMode as NewImagingMode + + new_mode = NewImagingMode[mode.name] + from pylabrobot.capabilities.microscopy.standard import Objective as NewObjective + + new_objective = NewObjective[objective.name] + + result = await self._new.capture( + row=row, + column=column, + mode=new_mode, + objective=new_objective, + exposure_time=exposure_time, + focal_height=focal_height, + gain=gain, + plate=plate, + **kwargs, + ) + return ImagingResult( + images=result.images, + exposure_time=result.exposure_time, + focal_height=result.focal_height, + ) diff --git a/pylabrobot/legacy/plate_reading/agilent/biotek_synergyh1_backend.py b/pylabrobot/legacy/plate_reading/agilent/biotek_synergyh1_backend.py new file mode 100644 index 00000000000..0ff482ea47f --- /dev/null +++ b/pylabrobot/legacy/plate_reading/agilent/biotek_synergyh1_backend.py @@ -0,0 +1,23 @@ +"""Legacy. Use pylabrobot.agilent instead.""" + +from pylabrobot.agilent.biotek import synergy_h1 +from pylabrobot.legacy.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend + + +class SynergyH1Backend(BioTekPlateReaderBackend): + """Legacy. Use pylabrobot.agilent.SynergyH1Backend instead.""" + + def __init__(self, timeout: float = 20, device_id=None) -> None: + self._new = synergy_h1.SynergyH1Backend(timeout=timeout, device_id=device_id) + + @property + def supports_heating(self): + return True + + @property + def supports_cooling(self): + return False + + @property + def focal_height_range(self): + return (4.5, 10.68) diff --git a/pylabrobot/plate_reading/backend.py b/pylabrobot/legacy/plate_reading/backend.py similarity index 93% rename from pylabrobot/plate_reading/backend.py rename to pylabrobot/legacy/plate_reading/backend.py index f793e18a023..7420e163b09 100644 --- a/pylabrobot/plate_reading/backend.py +++ b/pylabrobot/legacy/plate_reading/backend.py @@ -3,8 +3,8 @@ from abc import ABCMeta, abstractmethod from typing import Dict, List, Optional -from pylabrobot.machines.backend import MachineBackend -from pylabrobot.plate_reading.standard import ( +from pylabrobot.legacy.machines.backend import MachineBackend +from pylabrobot.legacy.plate_reading.standard import ( Exposure, FocalPosition, Gain, @@ -83,6 +83,8 @@ async def read_fluorescence( class ImagerBackend(MachineBackend, metaclass=ABCMeta): + """Legacy. Use pylabrobot.capabilities.microscopy.MicroscopyBackend instead.""" + @abstractmethod async def capture( self, diff --git a/pylabrobot/plate_reading/biotek_backend.py b/pylabrobot/legacy/plate_reading/biotek_backend.py similarity index 100% rename from pylabrobot/plate_reading/biotek_backend.py rename to pylabrobot/legacy/plate_reading/biotek_backend.py diff --git a/pylabrobot/plate_reading/bmg_labtech/__init__.py b/pylabrobot/legacy/plate_reading/bmg_labtech/__init__.py similarity index 100% rename from pylabrobot/plate_reading/bmg_labtech/__init__.py rename to pylabrobot/legacy/plate_reading/bmg_labtech/__init__.py diff --git a/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py b/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py new file mode 100644 index 00000000000..ede3155d9f9 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py @@ -0,0 +1,95 @@ +"""Legacy. Use pylabrobot.bmg_labtech instead.""" + +import sys +from typing import Dict, List, Optional, Tuple + +from pylabrobot.bmg_labtech import clariostar +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + + +class CLARIOstarBackend(PlateReaderBackend): + """Legacy. Use pylabrobot.bmg_labtech.CLARIOstarBackend instead.""" + + def __init__(self, device_id: Optional[str] = None): + self._new = clariostar.CLARIOstarBackend(device_id=device_id) + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def open(self): + await self._new.open() + + async def close(self, plate: Optional[Plate] = None): + await self._new.close() + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float = 13 + ) -> List[Dict]: + results = await self._new.read_luminescence(plate=plate, wells=wells, focal_height=focal_height) + return [ + { + "data": r.data, + "temperature": float("nan"), + "time": r.timestamp, + } + for r in results + ] + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + report: Literal["OD", "transmittance"] = "OD", + ) -> List[Dict]: + results = await self._new.read_absorbance( + plate=plate, wells=wells, wavelength=wavelength, report=report + ) + return [ + { + "wavelength": r.wavelength, + "data": r.data, + "temperature": float("nan"), + "time": r.timestamp, + } + for r in results + ] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict[Tuple[int, int], Dict]]: + raise NotImplementedError("Not implemented yet") + + +# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) +# https://github.com/PyLabRobot/pylabrobot/issues/466 + + +class CLARIOStar: + def __init__(self, *args, **kwargs): + raise RuntimeError("`CLARIOStar` is deprecated. Please use `CLARIOStarBackend` instead.") + + +class CLARIOStarBackend: + def __init__(self, *args, **kwargs): + raise RuntimeError( + "`CLARIOStarBackend` (capital 'S') is deprecated. Please use `CLARIOstarBackend` instead." + ) diff --git a/pylabrobot/plate_reading/byonoy/__init__.py b/pylabrobot/legacy/plate_reading/byonoy/__init__.py similarity index 67% rename from pylabrobot/plate_reading/byonoy/__init__.py rename to pylabrobot/legacy/plate_reading/byonoy/__init__.py index 9779834c3e9..a030282e63c 100644 --- a/pylabrobot/plate_reading/byonoy/__init__.py +++ b/pylabrobot/legacy/plate_reading/byonoy/__init__.py @@ -1,14 +1,21 @@ -from .byonoy_a96a import ( - byonoy_a96a, - byonoy_a96a_detection_unit, +"""Legacy. Use pylabrobot.byonoy instead.""" + +from pylabrobot.byonoy.absorbance_96 import ( + ByonoyAbsorbanceBaseUnit, byonoy_a96a_illumination_unit, byonoy_a96a_parking_unit, byonoy_sbs_adapter, ) +from pylabrobot.byonoy.luminescence_96 import ByonoyLuminescenceBaseUnit + +from .byonoy_a96a import ( + ByonoyAbsorbance96Automate, + byonoy_a96a, + byonoy_a96a_detection_unit, +) from .byonoy_backend import ByonoyAbsorbance96AutomateBackend, ByonoyLuminescence96AutomateBackend from .byonoy_l96 import ( ByonoyLuminescence96Automate, - ByonoyLuminescenceBaseUnit, byonoy_l96, byonoy_l96_base_unit, byonoy_l96_reader_unit, diff --git a/pylabrobot/legacy/plate_reading/byonoy/byonoy_a96a.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_a96a.py new file mode 100644 index 00000000000..36584687be6 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_a96a.py @@ -0,0 +1,40 @@ +"""Legacy. Use pylabrobot.byonoy instead.""" + +from typing import Tuple + +from pylabrobot.byonoy.absorbance_96 import ( + ByonoyAbsorbanceBaseUnit, + byonoy_a96a_illumination_unit, +) +from pylabrobot.legacy.plate_reading.byonoy.byonoy_backend import ByonoyAbsorbance96AutomateBackend +from pylabrobot.legacy.plate_reading.plate_reader import PlateReader +from pylabrobot.resources import Resource + + +class ByonoyAbsorbance96Automate(PlateReader, ByonoyAbsorbanceBaseUnit): + """Legacy. Use pylabrobot.byonoy.ByonoyAbsorbance96 instead.""" + + def __init__(self, name: str): + ByonoyAbsorbanceBaseUnit.__init__(self, name=name + "_base") + PlateReader.__init__( + self, + name=name + "_reader", + size_x=138, + size_y=95.7, + size_z=0, + backend=ByonoyAbsorbance96AutomateBackend(), + ) + + +def byonoy_a96a_detection_unit(name: str) -> ByonoyAbsorbance96Automate: + """Legacy. Use pylabrobot.byonoy.byonoy_a96a_detection_unit instead.""" + return ByonoyAbsorbance96Automate(name=name) + + +def byonoy_a96a(name: str, assign: bool = True) -> Tuple[ByonoyAbsorbance96Automate, Resource]: + """Legacy. Use pylabrobot.byonoy.byonoy_a96a instead.""" + reader = byonoy_a96a_detection_unit(name=name + "_reader") + illumination_unit = byonoy_a96a_illumination_unit(name=name + "_illumination_unit") + if assign: + reader.illumination_unit_holder.assign_child_resource(illumination_unit) + return reader, illumination_unit diff --git a/pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py new file mode 100644 index 00000000000..4d976417b24 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py @@ -0,0 +1,117 @@ +"""Legacy. Use pylabrobot.byonoy instead.""" + +from typing import Dict, List, Optional + +from pylabrobot.byonoy import absorbance_96, luminescence_96 +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources import Plate, Well + + +class ByonoyAbsorbance96AutomateBackend(PlateReaderBackend): + """Legacy. Use pylabrobot.byonoy.ByonoyAbsorbance96Backend instead.""" + + def __init__(self) -> None: + self._new = absorbance_96.ByonoyAbsorbance96Backend() + + async def setup(self, verbose: bool = False, **backend_kwargs): + await self._new.setup(**backend_kwargs) + + async def stop(self) -> None: + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def open(self) -> None: + raise NotImplementedError( + "byonoy cannot open by itself. you need to move the top module using a robot arm." + ) + + async def close(self, plate: Optional[Plate]) -> None: + raise NotImplementedError( + "byonoy cannot close by itself. you need to move the top module using a robot arm." + ) + + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: + results = await self._new.read_absorbance(plate=plate, wells=wells, wavelength=wavelength) + return [ + { + "wavelength": r.wavelength, + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float + ) -> List[Dict]: + raise NotImplementedError("Absorbance plate reader does not support luminescence reading.") + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict]: + raise NotImplementedError("Absorbance plate reader does not support fluorescence reading.") + + +class ByonoyLuminescence96AutomateBackend(PlateReaderBackend): + """Legacy. Use pylabrobot.byonoy.ByonoyLuminescence96Backend instead.""" + + def __init__(self) -> None: + self._new = luminescence_96.ByonoyLuminescence96Backend() + + async def setup(self) -> None: + await self._new.setup() + + async def stop(self) -> None: + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def open(self) -> None: + raise NotImplementedError( + "byonoy cannot open by itself. you need to move the top module using a robot arm." + ) + + async def close(self, plate: Optional[Plate]) -> None: + raise NotImplementedError( + "byonoy cannot close by itself. you need to move the top module using a robot arm." + ) + + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: + raise NotImplementedError( + "Luminescence plate reader does not support absorbance reading. " + "Use ByonoyAbsorbance96Automate instead." + ) + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 2 + ) -> List[Dict]: + results = await self._new.read_luminescence( + plate=plate, wells=wells, focal_height=focal_height, integration_time=integration_time + ) + return [ + { + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict]: + raise NotImplementedError("Luminescence plate reader does not support fluorescence reading.") diff --git a/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96.py new file mode 100644 index 00000000000..a52557f37fa --- /dev/null +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96.py @@ -0,0 +1,66 @@ +"""Legacy. Use pylabrobot.byonoy instead.""" + +from typing import Optional, Tuple + +from pylabrobot.byonoy.luminescence_96 import ByonoyLuminescenceBaseUnit +from pylabrobot.legacy.plate_reading.byonoy.byonoy_backend import ( + ByonoyLuminescence96AutomateBackend, +) +from pylabrobot.legacy.plate_reading.plate_reader import PlateReader +from pylabrobot.resources import Coordinate + + +class ByonoyLuminescence96Automate(PlateReader): + """Legacy. Use pylabrobot.byonoy.ByonoyLuminescence96 instead.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + preferred_pickup_location: Optional[Coordinate] = None, + ): + super().__init__( + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + backend=ByonoyLuminescence96AutomateBackend(), + model="Byonoy L96 Reader Unit", + preferred_pickup_location=preferred_pickup_location, + ) + + +def byonoy_l96_reader_unit(name: str) -> ByonoyLuminescence96Automate: + """Legacy. Use pylabrobot.byonoy.byonoy_l96_reader_unit instead.""" + return ByonoyLuminescence96Automate( + name=name, + size_x=139.7, + size_y=97.5, + size_z=35, + preferred_pickup_location=None, + ) + + +def byonoy_l96_base_unit(name: str) -> ByonoyLuminescenceBaseUnit: + """Legacy. Use pylabrobot.byonoy.byonoy_l96_base_unit instead.""" + return ByonoyLuminescenceBaseUnit( + name=name, + size_x=139.7, + size_y=97.5, + size_z=9.4, + plate_holder_child_location=Coordinate(x=6.25, y=6.1, z=2.64), + reader_unit_holder_child_location=Coordinate(x=0, y=0, z=7.2), + ) + + +def byonoy_l96( + name: str, assign: bool = True +) -> Tuple[ByonoyLuminescenceBaseUnit, ByonoyLuminescence96Automate]: + """Legacy. Use pylabrobot.byonoy.byonoy_l96 instead.""" + base_unit = byonoy_l96_base_unit(name=name + "_base") + reader_unit = byonoy_l96_reader_unit(name=name + "_reader") + if assign: + base_unit.reader_unit_holder.assign_child_resource(reader_unit) + return base_unit, reader_unit diff --git a/pylabrobot/plate_reading/byonoy/byonoy_l96a.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96a.py similarity index 51% rename from pylabrobot/plate_reading/byonoy/byonoy_l96a.py rename to pylabrobot/legacy/plate_reading/byonoy/byonoy_l96a.py index 56b2fe3d029..2f6946315b4 100644 --- a/pylabrobot/plate_reading/byonoy/byonoy_l96a.py +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_l96a.py @@ -1,48 +1,40 @@ +"""Legacy. Use pylabrobot.byonoy instead.""" + from typing import Tuple +from pylabrobot.byonoy.luminescence_96 import ByonoyLuminescenceBaseUnit from pylabrobot.resources import Coordinate -from .byonoy_l96 import ( - ByonoyLuminescence96Automate, - ByonoyLuminescenceBaseUnit, -) +from .byonoy_l96 import ByonoyLuminescence96Automate def byonoy_l96a_reader_unit(name: str) -> ByonoyLuminescence96Automate: - """Create a Byonoy L96A reader unit `PlateReader`.""" + """Legacy. Use pylabrobot.byonoy.byonoy_l96a_reader_unit instead.""" return ByonoyLuminescence96Automate( name=name, - size_x=138, # caliper - size_y=97.5, # caliper - size_z=41.7, # force z probing - preferred_pickup_location=Coordinate(x=69, y=48.75, z=33.2), # z = 41.7 - 8.5 + size_x=138, + size_y=97.5, + size_z=41.7, + preferred_pickup_location=Coordinate(x=69, y=48.75, z=33.2), ) def byonoy_l96a_base_unit(name: str) -> ByonoyLuminescenceBaseUnit: - """Create a Byonoy L96A base unit.""" + """Legacy. Use pylabrobot.byonoy.byonoy_l96a_base_unit instead.""" return ByonoyLuminescenceBaseUnit( name=name, - size_x=138, # caliper - size_y=97.5, # caliper - size_z=10.7, # force z probing - plate_holder_child_location=Coordinate(x=5.1, y=4.75, z=8), # caliper - reader_unit_holder_child_location=Coordinate(x=0, y=0, z=6.3), # z = 48 - 41.7 + size_x=138, + size_y=97.5, + size_z=10.7, + plate_holder_child_location=Coordinate(x=5.1, y=4.75, z=8), + reader_unit_holder_child_location=Coordinate(x=0, y=0, z=6.3), ) def byonoy_l96a( name: str, assign: bool = True ) -> Tuple[ByonoyLuminescenceBaseUnit, ByonoyLuminescence96Automate]: - """Creates a ByonoyLuminescenceBaseUnit and a PlateReader instance for L96A (automate). - - Args: - name: Base name for the resources. - assign: If True, the reader unit is assigned to the base unit's reader_unit_holder. - - Returns: - A tuple of (base_unit, reader_unit). - """ + """Legacy. Use pylabrobot.byonoy.byonoy_l96a instead.""" base_unit = byonoy_l96a_base_unit(name=name + "_base") reader_unit = byonoy_l96a_reader_unit(name=name + "_reader") if assign: diff --git a/pylabrobot/plate_reading/byonoy/byonoy_tests.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_tests.py similarity index 93% rename from pylabrobot/plate_reading/byonoy/byonoy_tests.py rename to pylabrobot/legacy/plate_reading/byonoy/byonoy_tests.py index 06cd42c868a..0c5563f0c29 100644 --- a/pylabrobot/plate_reading/byonoy/byonoy_tests.py +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_tests.py @@ -1,8 +1,8 @@ import unittest import unittest.mock -from pylabrobot.liquid_handling import LiquidHandler, LiquidHandlerBackend -from pylabrobot.plate_reading.byonoy import ( +from pylabrobot.legacy.liquid_handling import LiquidHandler, LiquidHandlerBackend +from pylabrobot.legacy.plate_reading.byonoy import ( byonoy_a96a, byonoy_sbs_adapter, ) diff --git a/pylabrobot/legacy/plate_reading/chatterbox.py b/pylabrobot/legacy/plate_reading/chatterbox.py new file mode 100644 index 00000000000..1b7be66913d --- /dev/null +++ b/pylabrobot/legacy/plate_reading/chatterbox.py @@ -0,0 +1,89 @@ +from typing import Dict, List, Optional + +from pylabrobot.capabilities.plate_reading.absorbance.chatterbox import ( + AbsorbanceChatterboxBackend, +) +from pylabrobot.capabilities.plate_reading.fluorescence.chatterbox import ( + FluorescenceChatterboxBackend, +) +from pylabrobot.capabilities.plate_reading.luminescence.chatterbox import ( + LuminescenceChatterboxBackend, +) +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.resources import Plate, Well + + +class PlateReaderChatterboxBackend(PlateReaderBackend): + """Chatterbox plate reader backend. Delegates to the new capability chatterbox backends.""" + + def __init__(self): + self._absorbance = AbsorbanceChatterboxBackend() + self._fluorescence = FluorescenceChatterboxBackend() + self._luminescence = LuminescenceChatterboxBackend() + + async def setup(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def open(self) -> None: + pass + + async def close(self, plate: Optional[Plate]) -> None: + pass + + async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: + results = await self._absorbance.read_absorbance( + plate=plate, wells=wells, wavelength=wavelength + ) + return [ + { + "wavelength": r.wavelength, + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + ) -> List[Dict]: + results = await self._fluorescence.read_fluorescence( + plate=plate, + wells=wells, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + focal_height=focal_height, + ) + return [ + { + "ex_wavelength": r.excitation_wavelength, + "em_wavelength": r.emission_wavelength, + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + + async def read_luminescence( + self, plate: Plate, wells: List[Well], focal_height: float + ) -> List[Dict]: + results = await self._luminescence.read_luminescence( + plate=plate, wells=wells, focal_height=focal_height + ) + return [ + { + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] diff --git a/pylabrobot/plate_reading/clario_star_backend.py b/pylabrobot/legacy/plate_reading/clario_star_backend.py similarity index 100% rename from pylabrobot/plate_reading/clario_star_backend.py rename to pylabrobot/legacy/plate_reading/clario_star_backend.py diff --git a/pylabrobot/plate_reading/image_reader.py b/pylabrobot/legacy/plate_reading/image_reader.py similarity index 70% rename from pylabrobot/plate_reading/image_reader.py rename to pylabrobot/legacy/plate_reading/image_reader.py index e340ccf0f51..a2090821599 100644 --- a/pylabrobot/plate_reading/image_reader.py +++ b/pylabrobot/legacy/plate_reading/image_reader.py @@ -1,13 +1,13 @@ from typing import Optional -from pylabrobot.plate_reading.backend import ImageReaderBackend -from pylabrobot.plate_reading.imager import Imager -from pylabrobot.plate_reading.plate_reader import PlateReader +from pylabrobot.legacy.plate_reading.backend import ImageReaderBackend +from pylabrobot.legacy.plate_reading.imager import Imager +from pylabrobot.legacy.plate_reading.plate_reader import PlateReader from pylabrobot.resources import Rotation class ImageReader(PlateReader, Imager): - """Microscope which is also a plate reader""" + """Legacy. Microscope which is also a plate reader.""" def __init__( self, diff --git a/pylabrobot/legacy/plate_reading/imager.py b/pylabrobot/legacy/plate_reading/imager.py new file mode 100644 index 00000000000..827e10077ad --- /dev/null +++ b/pylabrobot/legacy/plate_reading/imager.py @@ -0,0 +1,160 @@ +"""Legacy. Use pylabrobot.capabilities.microscopy.MicroscopyCapability instead.""" + +from typing import Optional, Tuple, Union, cast + +from pylabrobot.capabilities.microscopy import AutoExposure as NewAutoExposure +from pylabrobot.capabilities.microscopy import ImagingMode as NewImagingMode +from pylabrobot.capabilities.microscopy import ImagingResult as NewImagingResult +from pylabrobot.capabilities.microscopy import ( + MicroscopyBackend, + MicroscopyCapability, + evaluate_focus_nvmg_sobel, + fraction_overexposed, + max_pixel_at_fraction, +) +from pylabrobot.capabilities.microscopy import Objective as NewObjective +from pylabrobot.legacy.machines import Machine, need_setup_finished +from pylabrobot.legacy.plate_reading.backend import ImagerBackend +from pylabrobot.legacy.plate_reading.standard import ( + AutoExposure, + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + NoPlateError, + Objective, +) +from pylabrobot.resources import Plate, Resource, Rotation, Well + +# Re-export helpers so existing imports still work. +__all__ = [ + "Imager", + "max_pixel_at_fraction", + "fraction_overexposed", + "evaluate_focus_nvmg_sobel", +] + + +class _ImagerBackendAdapter(MicroscopyBackend): + """Adapts a legacy ImagerBackend to the new MicroscopyBackend protocol.""" + + def __init__(self, legacy: ImagerBackend): + self._legacy = legacy + + async def setup(self) -> None: + await self._legacy.setup() + + async def stop(self) -> None: + await self._legacy.stop() + + async def capture(self, row, column, mode, objective, exposure_time, focal_height, gain, plate): + legacy_mode = ImagingMode[mode.name] + legacy_obj = Objective[objective.name] + result = await self._legacy.capture( + row=row, + column=column, + mode=legacy_mode, + objective=legacy_obj, + exposure_time=exposure_time, + focal_height=focal_height, + gain=gain, + plate=plate, + ) + return NewImagingResult( + images=result.images, + exposure_time=result.exposure_time, + focal_height=result.focal_height, + ) + + +def _to_new_imaging_mode(mode: ImagingMode) -> NewImagingMode: + return NewImagingMode[mode.name] + + +def _to_new_objective(obj: Objective) -> NewObjective: + return NewObjective[obj.name] + + +def _to_legacy_result(result: NewImagingResult) -> ImagingResult: + return ImagingResult( + images=result.images, + exposure_time=result.exposure_time, + focal_height=result.focal_height, + ) + + +class Imager(Resource, Machine): + """Legacy. Use pylabrobot.molecular_devices.Pico (or similar Device) instead.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: ImagerBackend, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = None, + ): + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + ) + Machine.__init__(self, backend=backend) + self._backend: ImagerBackend = backend + self._microscopy = MicroscopyCapability(backend=_ImagerBackendAdapter(backend)) + self._microscopy._setup_finished = True # legacy Machine.setup() handles lifecycle + + self.register_will_assign_resource_callback(self._will_assign_resource) + + def _will_assign_resource(self, resource: Resource): + if len(self.children) >= 1: + raise ValueError( + f"Imager {self} already has a plate assigned (attempting to assign {resource})" + ) + + def get_plate(self) -> Plate: + if len(self.children) == 0: + raise NoPlateError("There is no plate in the plate reader.") + return cast(Plate, self.children[0]) + + @need_setup_finished + async def capture( + self, + well: Union[Well, Tuple[int, int]], + mode: ImagingMode, + objective: Objective, + exposure_time: Union[Exposure, AutoExposure] = "machine-auto", + focal_height: FocalPosition = "machine-auto", + gain: Gain = "machine-auto", + **backend_kwargs, + ) -> ImagingResult: + new_exposure: Union[float, str, NewAutoExposure] + if isinstance(exposure_time, AutoExposure): + new_exposure = NewAutoExposure( + evaluate_exposure=exposure_time.evaluate_exposure, + max_rounds=exposure_time.max_rounds, + low=exposure_time.low, + high=exposure_time.high, + ) + else: + new_exposure = exposure_time + new_result = await self._microscopy.capture( + well=well, + mode=_to_new_imaging_mode(mode), + objective=_to_new_objective(objective), + plate=self.get_plate(), + exposure_time=new_exposure, + focal_height=focal_height, + gain=gain, + **backend_kwargs, + ) + return _to_legacy_result(new_result) diff --git a/pylabrobot/plate_reading/molecular_devices/__init__.py b/pylabrobot/legacy/plate_reading/molecular_devices/__init__.py similarity index 90% rename from pylabrobot/plate_reading/molecular_devices/__init__.py rename to pylabrobot/legacy/plate_reading/molecular_devices/__init__.py index 4195d7f64ad..aa848a6b82d 100644 --- a/pylabrobot/plate_reading/molecular_devices/__init__.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/__init__.py @@ -1,8 +1,7 @@ -from .backend import ( +from pylabrobot.molecular_devices.spectramax.backend import ( Calibrate, CarriageSpeed, KineticSettings, - MolecularDevicesBackend, MolecularDevicesError, MolecularDevicesFirmwareError, MolecularDevicesHardwareError, @@ -17,6 +16,8 @@ ShakeSettings, SpectrumSettings, ) + +from .backend import MolecularDevicesBackend from .spectramax_384_plus_backend import MolecularDevicesSpectraMax384PlusBackend from .spectramax_m5_backend import MolecularDevicesSpectraMaxM5Backend diff --git a/pylabrobot/legacy/plate_reading/molecular_devices/backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py new file mode 100644 index 00000000000..1383dad3da9 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py @@ -0,0 +1,319 @@ +"""Legacy. Use pylabrobot.molecular_devices.spectramax instead.""" + +from typing import Dict, List, Optional, Tuple, Union + +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.molecular_devices.spectramax.backend import ( # noqa: F401 + Calibrate, + CarriageSpeed, + KineticSettings, + MolecularDevicesError, + MolecularDevicesFirmwareError, + MolecularDevicesHardwareError, + MolecularDevicesMotionError, + MolecularDevicesNVRAMError, + MolecularDevicesSettings, + MolecularDevicesUnrecognizedCommandError, + PmtGain, + ReadMode, + ReadOrder, + ReadType, + ShakeSettings, + SpectrumSettings, +) +from pylabrobot.molecular_devices.spectramax.spectramax_m5 import SpectraMaxM5Backend +from pylabrobot.resources.plate import Plate + + +class MolecularDevicesBackend(PlateReaderBackend): + """Legacy. Use pylabrobot.molecular_devices.spectramax.MolecularDevicesBackend instead. + + Delegates to the new capability-based backend, adapting read method signatures + and return types (List[Dict]) for backward compatibility. + """ + + def __init__(self, port: str) -> None: + self._new: SpectraMaxM5Backend = self._make_new_backend(port) + + # Bridge internal methods so test mocks on self.* intercept calls from _new. + self._real_send_command = self._new.send_command + self._real_read_now = self._new._read_now + self._real_wait_for_idle = self._new._wait_for_idle + self._real_transfer_data = self._new._transfer_data + + async def _sc(*a, **kw): + return await self.send_command(*a, **kw) + + async def _rn(): + return await self._read_now() + + async def _wfi(**kw): + return await self._wait_for_idle(**kw) + + async def _td(*a, **kw): + return await self._transfer_data(*a, **kw) + + self._new.send_command = _sc + self._new._read_now = _rn + self._new._wait_for_idle = _wfi + self._new._transfer_data = _td + + def _make_new_backend(self, port: str): + return SpectraMaxM5Backend(port=port) + + # -- PlateReaderBackend / MachineBackend interface ----------------------- + + async def setup(self) -> None: + await self._new.setup() + + async def stop(self) -> None: + await self._new.stop() + + async def open(self) -> None: + await self._new.open() + + async def close(self, plate=None) -> None: + await self._new.close(plate=plate) + + async def send_command(self, *args, **kwargs): + return await self._real_send_command(*args, **kwargs) + + def serialize(self) -> dict: + return dict(self._new.serialize()) + + # -- Bridged internals (must be explicit for class-level @patch) --------- + + async def _read_now(self): + return await self._real_read_now() + + async def _wait_for_idle(self, **kwargs): + return await self._real_wait_for_idle(**kwargs) + + async def _transfer_data(self, *args, **kwargs): + return await self._real_transfer_data(*args, **kwargs) + + # -- Legacy read methods (delegate to _new, convert results) ------------- + + async def read_absorbance( # type: ignore[override] + self, + plate: Plate, + wavelengths: List[Union[int, Tuple[int, bool]]], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + speed_read: bool = False, + path_check: bool = False, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + wl0 = wavelengths[0] + wavelength = wl0[0] if isinstance(wl0, tuple) else wl0 + results = await self._new.read_absorbance( + plate=plate, + wells=[], + wavelength=wavelength, + wavelengths=wavelengths, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + speed_read=speed_read, + path_check=path_check, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + cuvette=cuvette, + settling_time=settling_time, + timeout=timeout, + ) + return [ + { + "wavelength": r.wavelength, + "data": r.data, + "temperature": r.temperature, + "time": r.timestamp, + } + for r in results + ] + + async def read_fluorescence( # type: ignore[override] + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + results = await self._new.read_fluorescence( + plate=plate, + wells=[], + excitation_wavelength=excitation_wavelengths[0], + emission_wavelength=emission_wavelengths[0], + focal_height=0, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + cuvette=cuvette, + settling_time=settling_time, + timeout=timeout, + ) + return [ + { + "ex_wavelength": r.excitation_wavelength, + "em_wavelength": r.emission_wavelength, + "data": r.data, + "temperature": r.temperature, + "time": r.timestamp, + } + for r in results + ] + + async def read_luminescence( # type: ignore[override] + self, + plate: Plate, + emission_wavelengths: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 0, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + results = await self._new.read_luminescence( + plate=plate, + wells=[], + focal_height=0, + emission_wavelengths=emission_wavelengths, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + cuvette=cuvette, + settling_time=settling_time, + timeout=timeout, + ) + return [{"data": r.data, "temperature": r.temperature, "time": r.timestamp} for r in results] + + async def read_fluorescence_polarization( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + return await self._new.read_fluorescence_polarization( + plate=plate, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + cuvette=cuvette, + settling_time=settling_time, + timeout=timeout, + ) + + async def read_time_resolved_fluorescence( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + delay_time: int, + integration_time: int, + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 50, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + return await self._new.read_time_resolved_fluorescence( + plate=plate, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + delay_time=delay_time, + integration_time=integration_time, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + cuvette=cuvette, + settling_time=settling_time, + timeout=timeout, + ) diff --git a/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py b/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py new file mode 100644 index 00000000000..d7879484438 --- /dev/null +++ b/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py @@ -0,0 +1,1001 @@ +import math +import unittest +from unittest.mock import AsyncMock, MagicMock, call, patch + +from pylabrobot.legacy.plate_reading.molecular_devices.backend import ( + Calibrate, + CarriageSpeed, + KineticSettings, + MolecularDevicesBackend, + MolecularDevicesError, + MolecularDevicesSettings, + MolecularDevicesUnrecognizedCommandError, + PmtGain, + ReadMode, + ReadOrder, + ReadType, + ShakeSettings, + SpectrumSettings, +) +from pylabrobot.resources.agenbio.plates import AGenBio_96_wellplate_Ub_2200ul + + +class TestMolecularDevicesBackend(unittest.IsolatedAsyncioTestCase): + backend: MolecularDevicesBackend + mock_serial: MagicMock + send_command_mock: AsyncMock + + def setUp(self): + self.mock_serial = MagicMock() + self.mock_serial.setup = AsyncMock() + self.mock_serial.stop = AsyncMock() + self.mock_serial.write = AsyncMock() + self.mock_serial.readline = AsyncMock(return_value=b"OK>\r\n") + + with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): + self.backend = MolecularDevicesBackend(port="COM1") + self.backend._new.io = self.mock_serial + self.send_command_mock = patch.object( + self.backend, "send_command", new_callable=AsyncMock + ).start() + self.addCleanup(patch.stopall) + + async def test_setup_stop(self): + # un-mock send_command for this test + with patch.object( + self.backend, "send_command", wraps=self.backend.send_command + ) as wrapped_send_command: + await self.backend.setup() + self.mock_serial.setup.assert_called_once() + wrapped_send_command.assert_called_with("!") + await self.backend.stop() + self.mock_serial.stop.assert_called_once() + + async def test_set_clear(self): + await self.backend._new._set_clear() + self.send_command_mock.assert_called_once_with("!CLEAR DATA") + + async def test_set_mode(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_mode(settings) + self.send_command_mock.assert_called_once_with("!MODE ENDPOINT") + + self.send_command_mock.reset_mock() + settings.read_type = ReadType.KINETIC + settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) + await self.backend._new._set_mode(settings) + self.send_command_mock.assert_called_once_with("!MODE KINETIC 10 5") + + self.send_command_mock.reset_mock() + settings.read_type = ReadType.SPECTRUM + settings.spectrum_settings = SpectrumSettings(start_wavelength=200, step=10, num_steps=50) + await self.backend._new._set_mode(settings) + self.send_command_mock.assert_called_once_with("!MODE SPECTRUM 200 10 50") + + self.send_command_mock.reset_mock() + settings.spectrum_settings.excitation_emission_type = "EXSPECTRUM" + await self.backend._new._set_mode(settings) + self.send_command_mock.assert_called_once_with("!MODE EXSPECTRUM 200 10 50") + + async def test_set_wavelengths(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + wavelengths=[500, (600, True)], + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_wavelengths(settings) + self.send_command_mock.assert_called_once_with("!WAVELENGTH 500 F600") + + self.send_command_mock.reset_mock() + settings.path_check = True + await self.backend._new._set_wavelengths(settings) + self.send_command_mock.assert_called_once_with("!WAVELENGTH 500 F600 900 998") + + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.FLU + settings.excitation_wavelengths = [485] + settings.emission_wavelengths = [520] + await self.backend._new._set_wavelengths(settings) + self.send_command_mock.assert_has_calls([call("!EXWAVELENGTH 485"), call("!EMWAVELENGTH 520")]) + + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.LUM + settings.emission_wavelengths = [590] + await self.backend._new._set_wavelengths(settings) + self.send_command_mock.assert_called_once_with("!EMWAVELENGTH 590") + + async def test_set_plate_position(self): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_plate_position(settings) + self.send_command_mock.assert_has_calls( + [call("!XPOS 13.380 9.000 12"), call("!YPOS 12.240 9.000 8")] + ) + + async def test_set_strip(self): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_strip(settings) + self.send_command_mock.assert_called_once_with("!STRIP 1 12") + + async def test_set_shake(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_shake(settings) + self.send_command_mock.assert_called_once_with("!SHAKE OFF") + + self.send_command_mock.reset_mock() + settings.shake_settings = ShakeSettings(before_read=True, before_read_duration=5) + await self.backend._new._set_shake(settings) + self.send_command_mock.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 5 0 0 0 0")]) + + self.send_command_mock.reset_mock() + settings.shake_settings = ShakeSettings(between_reads=True, between_reads_duration=3) + settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) + await self.backend._new._set_shake(settings) + self.send_command_mock.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 0 10 7 3 0")]) + + async def test_set_carriage_speed(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_carriage_speed(settings) + self.send_command_mock.assert_called_once_with("!CSPEED 8") + self.send_command_mock.reset_mock() + settings.carriage_speed = CarriageSpeed.SLOW + await self.backend._new._set_carriage_speed(settings) + self.send_command_mock.assert_called_once_with("!CSPEED 1") + + async def test_set_read_stage(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_read_stage(settings) + self.send_command_mock.assert_called_once_with("!READSTAGE TOP") + self.send_command_mock.reset_mock() + settings.read_from_bottom = True + await self.backend._new._set_read_stage(settings) + self.send_command_mock.assert_called_once_with("!READSTAGE BOT") + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.ABS + await self.backend._new._set_read_stage(settings) + self.send_command_mock.assert_not_called() + + async def test_set_flashes_per_well(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + flashes_per_well=10, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_flashes_per_well(settings) + self.send_command_mock.assert_called_once_with("!FPW 10") + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.ABS + await self.backend._new._set_flashes_per_well(settings) + self.send_command_mock.assert_not_called() + + async def test_set_pmt(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + pmt_gain=PmtGain.AUTO, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_pmt(settings) + self.send_command_mock.assert_called_once_with("!AUTOPMT ON") + self.send_command_mock.reset_mock() + settings.pmt_gain = PmtGain.HIGH + await self.backend._new._set_pmt(settings) + self.send_command_mock.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT HIGH")]) + self.send_command_mock.reset_mock() + settings.pmt_gain = 9 + await self.backend._new._set_pmt(settings) + self.send_command_mock.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT 9")]) + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.ABS + await self.backend._new._set_pmt(settings) + self.send_command_mock.assert_not_called() + + async def test_set_filter(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + cutoff_filters=[self.backend._new._get_cutoff_filter_index_from_wavelength(535), 9], + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_filter(settings) + self.send_command_mock.assert_has_calls([call("!AUTOFILTER OFF"), call("!EMFILTER 8 9")]) + self.send_command_mock.reset_mock() + settings.cutoff_filters = [] + await self.backend._new._set_filter(settings) + self.send_command_mock.assert_called_once_with("!AUTOFILTER ON") + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.ABS + settings.cutoff_filters = [515, 530] + await self.backend._new._set_filter(settings) + self.send_command_mock.assert_called_once_with("!AUTOFILTER ON") + + async def test_set_calibrate(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_calibrate(settings) + self.send_command_mock.assert_called_once_with("!CALIBRATE ON") + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.FLU + await self.backend._new._set_calibrate(settings) + self.send_command_mock.assert_called_once_with("!PMTCAL ON") + + async def test_set_order(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_order(settings) + self.send_command_mock.assert_called_once_with("!ORDER COLUMN") + self.send_command_mock.reset_mock() + settings.read_order = ReadOrder.WAVELENGTH + await self.backend._new._set_order(settings) + self.send_command_mock.assert_called_once_with("!ORDER WAVELENGTH") + + async def test_set_speed(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=True, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_speed(settings) + self.send_command_mock.assert_called_once_with("!SPEED ON") + self.send_command_mock.reset_mock() + settings.speed_read = False + await self.backend._new._set_speed(settings) + self.send_command_mock.assert_called_once_with("!SPEED OFF") + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.FLU + await self.backend._new._set_speed(settings) + self.send_command_mock.assert_not_called() + + async def test_set_integration_time(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.TIME, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + await self.backend._new._set_integration_time(settings, 10, 100) + self.send_command_mock.assert_has_calls([call("!COUNTTIMEDELAY 10"), call("!COUNTTIME 0.1")]) + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.ABS + await self.backend._new._set_integration_time(settings, 10, 100) + self.send_command_mock.assert_not_called() + + async def test_set_nvram_polar(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.POLAR, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + settling_time=5, + ) + await self.backend._new._set_nvram(settings) + self.send_command_mock.assert_called_once_with("!NVRAM FPSETTLETIME 5") + + async def test_set_nvram_other(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + settling_time=10, + ) + await self.backend._new._set_nvram(settings) + self.send_command_mock.assert_called_once_with("!NVRAM CARCOL 100") + self.send_command_mock.reset_mock() + settings.settling_time = 110 + await self.backend._new._set_nvram(settings) + self.send_command_mock.assert_called_once_with("!NVRAM CARCOL 110") + + async def test_set_tag(self): + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.POLAR, + read_type=ReadType.KINETIC, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=KineticSettings(interval=10, num_readings=5), + spectrum_settings=None, + ) + await self.backend._new._set_tag(settings) + self.send_command_mock.assert_called_once_with("!TAG ON") + self.send_command_mock.reset_mock() + settings.read_type = ReadType.ENDPOINT + await self.backend._new._set_tag(settings) + self.send_command_mock.assert_called_once_with("!TAG OFF") + self.send_command_mock.reset_mock() + settings.read_mode = ReadMode.ABS + settings.read_type = ReadType.KINETIC + await self.backend._new._set_tag(settings) + self.send_command_mock.assert_called_once_with("!TAG OFF") + + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + new_callable=AsyncMock, + return_value="", + ) + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + new_callable=AsyncMock, + ) + async def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + await self.backend.read_absorbance(plate, [500]) + + commands = [c.args[0] for c in self.send_command_mock.call_args_list] + self.assertIn("!CLEAR DATA", commands) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!WAVELENGTH 500", commands) + self.assertIn("!CALIBRATE ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn("!SPEED OFF", commands) + + readtype_call = next( + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE ABSPLA" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 2}) + + mock_read_now.assert_called_once() + mock_wait_for_idle.assert_called_once() + mock_transfer_data.assert_called_once() + + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + new_callable=AsyncMock, + return_value="", + ) + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + new_callable=AsyncMock, + ) + async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + await self.backend.read_fluorescence(plate, [485], [520], [515]) + + commands = [c.args[0] for c in self.send_command_mock.call_args_list] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!FPW 10", commands) + self.assertIn("!AUTOPMT ON", commands) + self.assertIn("!EXWAVELENGTH 485", commands) + self.assertIn("!EMWAVELENGTH 520", commands) + self.assertIn("!AUTOFILTER OFF", commands) + self.assertIn("!EMFILTER 515", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn("!READSTAGE TOP", commands) + + readtype_call = next( + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE FLU" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) + + mock_read_now.assert_called_once() + mock_wait_for_idle.assert_called_once() + mock_transfer_data.assert_called_once() + + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + new_callable=AsyncMock, + return_value="", + ) + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + new_callable=AsyncMock, + ) + async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + await self.backend.read_luminescence(plate, [590]) + + commands = [c.args[0] for c in self.send_command_mock.call_args_list] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!EMWAVELENGTH 590", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn("!READSTAGE TOP", commands) + + readtype_call = next( + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE LUM" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) + + mock_read_now.assert_called_once() + mock_wait_for_idle.assert_called_once() + mock_transfer_data.assert_called_once() + + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + new_callable=AsyncMock, + return_value="", + ) + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + new_callable=AsyncMock, + ) + async def test_read_fluorescence_polarization( + self, + mock_read_now, + mock_transfer_data, + mock_wait_for_idle, + ): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + await self.backend.read_fluorescence_polarization(plate, [485], [520], [515]) + + commands = [c.args[0] for c in self.send_command_mock.call_args_list] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!FPW 10", commands) + self.assertIn("!AUTOPMT ON", commands) + self.assertIn("!EXWAVELENGTH 485", commands) + self.assertIn("!EMWAVELENGTH 520", commands) + self.assertIn("!AUTOFILTER OFF", commands) + self.assertIn("!EMFILTER 515", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn("!READSTAGE TOP", commands) + + readtype_call = next( + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE POLAR" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) + + mock_read_now.assert_called_once() + mock_wait_for_idle.assert_called_once() + mock_transfer_data.assert_called_once() + + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + new_callable=AsyncMock, + ) + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + new_callable=AsyncMock, + return_value="", + ) + @patch( + "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + new_callable=AsyncMock, + ) + async def test_read_time_resolved_fluorescence( + self, + mock_read_now, + mock_transfer_data, + mock_wait_for_idle, + ): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + await self.backend.read_time_resolved_fluorescence( + plate, [485], [520], [515], delay_time=10, integration_time=100 + ) + + commands = [c.args[0] for c in self.send_command_mock.call_args_list] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!FPW 50", commands) + self.assertIn("!AUTOPMT ON", commands) + self.assertIn("!EXWAVELENGTH 485", commands) + self.assertIn("!EMWAVELENGTH 520", commands) + self.assertIn("!AUTOFILTER OFF", commands) + self.assertIn("!EMFILTER 515", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn("!COUNTTIMEDELAY 10", commands) + self.assertIn("!COUNTTIME 0.1", commands) + self.assertIn("!READSTAGE TOP", commands) + + readtype_call = next( + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE TIME 0 250" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) + + mock_read_now.assert_called_once() + mock_wait_for_idle.assert_called_once() + mock_transfer_data.assert_called_once() + + +class TestDataParsing(unittest.IsolatedAsyncioTestCase): + send_command_mock: AsyncMock + + def setUp(self): + with patch("pylabrobot.io.serial.Serial", return_value=MagicMock()): + self.backend = MolecularDevicesBackend(port="COM1") + self.send_command_mock = patch.object( + self.backend, "send_command", new_callable=AsyncMock + ).start() + + def test_parse_absorbance_single_wavelength(self): + data_str = """ + 12345.6 25.1 96-well + L: 260 + L: 260 + 1: 0.1 0.2 + 2: 0.3 0.4 + """ + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + + result = self.backend._new._parse_data(data_str, settings) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + read = result[0] + self.assertEqual(read["wavelength"], 260) + self.assertEqual(read["time"], 12345.6) + self.assertEqual(read["temperature"], 25.1) + self.assertEqual(read["data"], [[0.1, 0.3], [0.2, 0.4]]) + + def test_parse_absorbance_multiple_wavelengths(self): + data_str = """ + 12345.6\t25.1\t96-well + L:\t260\t280 + L:\t260 + 1:\t0.1\t0.2 + 2:\t0.3\t0.4 + L:\t280 + 1:\t0.5\t0.6 + 2:\t0.7\t0.8 + """ + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + result = self.backend._new._parse_data(data_str, settings) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["wavelength"], 260) + self.assertEqual(result[0]["data"], [[0.1, 0.3], [0.2, 0.4]]) + self.assertEqual(result[1]["wavelength"], 280) + self.assertEqual(result[1]["data"], [[0.5, 0.7], [0.6, 0.8]]) + + def test_parse_fluorescence(self): + data_str = """ + 12345.6\t25.1\t96-well + exL:\t485 + emL:\t520 + L:\t485\t520 + 1:\t100\t200 + 2:\t300\t400 + """ + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.FLU, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + result = self.backend._new._parse_data(data_str, settings) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + read = result[0] + self.assertEqual(read["ex_wavelength"], 485) + self.assertEqual(read["em_wavelength"], 520) + self.assertEqual(read["time"], 12345.6) + self.assertEqual(read["temperature"], 25.1) + self.assertEqual(read["data"], [[100.0, 300.0], [200.0, 400.0]]) + + def test_parse_luminescence(self): + data_str = """ + 12345.6\t25.1\t96-well + emL:\t590 + L:\t\t590 + 1:\t1000\t2000 + 2:\t3000\t4000 + """ + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.LUM, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + result = self.backend._new._parse_data(data_str, settings) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + read = result[0] + self.assertEqual(read["em_wavelength"], 590) + self.assertEqual(read["time"], 12345.6) + self.assertEqual(read["temperature"], 25.1) + self.assertEqual(read["data"], [[1000.0, 3000.0], [2000.0, 4000.0]]) + + def test_parse_data_with_sat_and_nan(self): + data_str = """ + 12345.6\t25.1\t96-well + L:\t260 + L:\t260 + 1:\t0.1\t#SAT + 2:\t0.3\t- + """ + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.ENDPOINT, + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + spectrum_settings=None, + ) + result = self.backend._new._parse_data(data_str, settings) + self.assertIsInstance(result, list) + self.assertEqual(len(result), 1) + read = result[0] + self.assertEqual(read["data"][1][0], float("inf")) + self.assertTrue(math.isnan(read["data"][1][1])) + + async def test_parse_kinetic_absorbance(self): + # Mock the send_command to return two different data blocks + def data_generator(): + yield [ + "OK", + """ + 12345.6\t25.1\t96-well + L:\t260 + L:\t260 + 1:\t0.1\t0.2 + 2:\t0.3\t0.4 + """, + ] + yield [ + "OK", + """ + 12355.6\t25.2\t96-well + L:\t260 + L:\t260 + 1:\t0.15\t0.25 + 2:\t0.35\t0.45 + """, + ] + + self.send_command_mock.side_effect = data_generator() + + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.KINETIC, + kinetic_settings=KineticSettings(interval=10, num_readings=2), + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + spectrum_settings=None, + ) + + result = await self.backend._new._transfer_data(settings) + self.assertEqual(len(result), 2) + self.assertEqual(result[0]["wavelength"], 260) + self.assertEqual(result[0]["data"], [[0.1, 0.3], [0.2, 0.4]]) + self.assertEqual(result[0]["time"], 12345.6) + self.assertEqual(result[1]["wavelength"], 260) + self.assertEqual(result[1]["data"], [[0.15, 0.35], [0.25, 0.45]]) + self.assertEqual(result[1]["time"], 12355.6) + + async def test_parse_spectrum_absorbance(self): + # Mock the send_command to return two different data blocks for two wavelengths + def data_generator(): + yield [ + "OK", + """ + 12345.6\t25.1\t96-well + L:\t260 + L:\t260 + 1:\t0.1\t0.2 + 2:\t0.3\t0.4 + """, + ] + yield [ + "OK", + """ + 12355.6\t25.2\t96-well + L:\t270 + L:\t270 + 1:\t0.15\t0.25 + 2:\t0.35\t0.45 + """, + ] + + self.send_command_mock.side_effect = data_generator() + + settings = MolecularDevicesSettings( + plate=MagicMock(), + read_mode=ReadMode.ABS, + read_type=ReadType.SPECTRUM, + spectrum_settings=SpectrumSettings(start_wavelength=260, step=10, num_steps=2), + read_order=ReadOrder.COLUMN, + calibrate=Calibrate.ON, + shake_settings=None, + carriage_speed=CarriageSpeed.NORMAL, + speed_read=False, + kinetic_settings=None, + ) + + result = await self.backend._new._transfer_data(settings) + self.assertEqual(len(result), 2) + + self.assertEqual(result[0]["wavelength"], 260) + self.assertEqual(result[0]["data"], [[0.1, 0.3], [0.2, 0.4]]) + self.assertEqual(result[0]["time"], 12345.6) + + self.assertEqual(result[1]["wavelength"], 270) + self.assertEqual(result[1]["data"], [[0.15, 0.35], [0.25, 0.45]]) + self.assertEqual(result[1]["time"], 12355.6) + + +class TestErrorHandling(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.mock_serial = MagicMock() + self.mock_serial.setup = AsyncMock() + self.mock_serial.stop = AsyncMock() + self.mock_serial.write = AsyncMock() + self.mock_serial.readline = AsyncMock() + + with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): + self.backend = MolecularDevicesBackend(port="/dev/tty01") + self.backend._new.io = self.mock_serial + + async def _mock_send_command_response(self, response_str: str): + self.mock_serial.readline.side_effect = [response_str.encode() + b">\r\n"] + return await self.backend.send_command("!TEST") + + async def test_parse_basic_errors_fail_known_error_code(self): + # Test a known error code (e.g., 107: no data to transfer) + with self.assertRaisesRegex( + MolecularDevicesUnrecognizedCommandError, + "Command '!TEST' failed with error 107: no data to transfer", + ): + await self._mock_send_command_response("OK\t\r\n>FAIL\t 107") + + async def test_parse_basic_errors_fail_unknown_error_code(self): + # Test an unknown error code + with self.assertRaisesRegex( + MolecularDevicesError, "Command '!TEST' failed with unknown error code: 999" + ): + await self._mock_send_command_response("FAIL\t 999") + + async def test_parse_basic_errors_fail_unparsable_error(self): + # Test an unparsable error message (e.g., not an integer code) + with self.assertRaisesRegex( + MolecularDevicesError, "Command '!TEST' failed with unparsable error: FAIL\t ABC" + ): + await self._mock_send_command_response("FAIL\t ABC") + + async def test_parse_basic_errors_empty_response(self): + # Test an empty response from the device + self.mock_serial.readline.return_value = b"" # Simulate no response + with self.assertRaisesRegex(TimeoutError, "Timeout waiting for response to command: !TEST"): + await self.backend.send_command("!TEST", timeout=1) # Short timeout for test + + async def test_parse_basic_errors_warning_response(self): + # Test a response containing a warning + self.mock_serial.readline.side_effect = [b"OK\tWarning: Something happened>\r\n"] + # Expect no exception, but a warning logged (not directly testable with assertRaises) + # We can assert that no error is raised. + try: + await self.backend.send_command("!TEST") + except MolecularDevicesError: + self.fail("MolecularDevicesError raised for a warning response") + + async def test_parse_basic_errors_ok_response(self): + # Test a normal OK response + self.mock_serial.readline.side_effect = [b"OK>\r\n"] + try: + response = await self.backend.send_command("!TEST") + self.assertEqual(response, ["OK"]) + except MolecularDevicesError: + self.fail("MolecularDevicesError raised for a valid OK response") + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/plate_reading/molecular_devices/spectramax_384_plus_backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py similarity index 84% rename from pylabrobot/plate_reading/molecular_devices/spectramax_384_plus_backend.py rename to pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py index ce3bbc758e5..a3ebf92fb3b 100644 --- a/pylabrobot/plate_reading/molecular_devices/spectramax_384_plus_backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py @@ -1,37 +1,32 @@ -from typing import Dict, List, Optional, Union +"""Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMax384PlusBackend instead.""" -from pylabrobot.resources.plate import Plate +from typing import Dict, List, Optional, Union -from .backend import ( +from pylabrobot.molecular_devices.spectramax.backend import ( Calibrate, CarriageSpeed, KineticSettings, - MolecularDevicesBackend, - MolecularDevicesSettings, PmtGain, ReadOrder, ReadType, ShakeSettings, SpectrumSettings, ) +from pylabrobot.molecular_devices.spectramax.spectramax_384_plus import SpectraMax384PlusBackend +from pylabrobot.resources.plate import Plate +from .backend import MolecularDevicesBackend -class MolecularDevicesSpectraMax384PlusBackend(MolecularDevicesBackend): - """Backend for Molecular Devices SpectraMax 384 Plus plate readers.""" - - def __init__(self, port: str) -> None: - super().__init__(port) - async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: - """Set the READTYPE command and the expected number of response fields.""" - cmd = f"!READTYPE {'CUV' if settings.cuvette else 'PLA'}" - await self.send_command(cmd, num_res_fields=1) +class MolecularDevicesSpectraMax384PlusBackend(MolecularDevicesBackend): + """Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMax384PlusBackend instead. - async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: - pass + Delegates to SpectraMax384PlusBackend (which has its own _set_readtype/_set_nvram/_set_tag + overrides), and raises NotImplementedError for unsupported read modes. + """ - async def _set_tag(self, settings: MolecularDevicesSettings) -> None: - pass + def _make_new_backend(self, port: str): + return SpectraMax384PlusBackend(port=port) async def read_fluorescence( # type: ignore[override] self, diff --git a/pylabrobot/plate_reading/molecular_devices/spectramax_m5_backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_m5_backend.py similarity index 50% rename from pylabrobot/plate_reading/molecular_devices/spectramax_m5_backend.py rename to pylabrobot/legacy/plate_reading/molecular_devices/spectramax_m5_backend.py index 7628fbbf80d..7ae92c95c54 100644 --- a/pylabrobot/plate_reading/molecular_devices/spectramax_m5_backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_m5_backend.py @@ -1,8 +1,10 @@ +"""Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMaxM5Backend instead.""" + from .backend import MolecularDevicesBackend class MolecularDevicesSpectraMaxM5Backend(MolecularDevicesBackend): - """Backend for Molecular Devices SpectraMax M5 plate readers.""" + """Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMaxM5Backend instead.""" def __init__(self, port: str) -> None: super().__init__(port) diff --git a/pylabrobot/plate_reading/plate_reader.py b/pylabrobot/legacy/plate_reading/plate_reader.py similarity index 96% rename from pylabrobot/plate_reading/plate_reader.py rename to pylabrobot/legacy/plate_reading/plate_reader.py index a44e6783261..adfc2584627 100644 --- a/pylabrobot/plate_reading/plate_reader.py +++ b/pylabrobot/legacy/plate_reading/plate_reader.py @@ -1,9 +1,9 @@ import logging from typing import Dict, List, Optional, cast -from pylabrobot.machines.machine import Machine, need_setup_finished -from pylabrobot.plate_reading.backend import PlateReaderBackend -from pylabrobot.plate_reading.standard import NoPlateError +from pylabrobot.legacy.machines.machine import Machine, need_setup_finished +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.legacy.plate_reading.standard import NoPlateError from pylabrobot.resources import Coordinate, Plate, Resource, ResourceHolder, Rotation, Well logger = logging.getLogger(__name__) diff --git a/pylabrobot/plate_reading/plate_reader_tests.py b/pylabrobot/legacy/plate_reading/plate_reader_tests.py similarity index 87% rename from pylabrobot/plate_reading/plate_reader_tests.py rename to pylabrobot/legacy/plate_reading/plate_reader_tests.py index 1cea4ac7b87..bd61f1bc296 100644 --- a/pylabrobot/plate_reading/plate_reader_tests.py +++ b/pylabrobot/legacy/plate_reading/plate_reader_tests.py @@ -1,7 +1,7 @@ import unittest -from pylabrobot.plate_reading import PlateReader -from pylabrobot.plate_reading.chatterbox import PlateReaderChatterboxBackend +from pylabrobot.legacy.plate_reading import PlateReader +from pylabrobot.legacy.plate_reading.chatterbox import PlateReaderChatterboxBackend from pylabrobot.resources import Plate diff --git a/pylabrobot/plate_reading/standard.py b/pylabrobot/legacy/plate_reading/standard.py similarity index 100% rename from pylabrobot/plate_reading/standard.py rename to pylabrobot/legacy/plate_reading/standard.py diff --git a/pylabrobot/plate_reading/tecan/__init__.py b/pylabrobot/legacy/plate_reading/tecan/__init__.py similarity index 100% rename from pylabrobot/plate_reading/tecan/__init__.py rename to pylabrobot/legacy/plate_reading/tecan/__init__.py diff --git a/pylabrobot/plate_reading/tecan/infinite_backend.py b/pylabrobot/legacy/plate_reading/tecan/infinite_backend.py similarity index 99% rename from pylabrobot/plate_reading/tecan/infinite_backend.py rename to pylabrobot/legacy/plate_reading/tecan/infinite_backend.py index 1c6423d089b..e4e2bd650ff 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend.py +++ b/pylabrobot/legacy/plate_reading/tecan/infinite_backend.py @@ -17,7 +17,7 @@ from pylabrobot.io.binary import Reader from pylabrobot.io.usb import USB -from pylabrobot.plate_reading.backend import PlateReaderBackend +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend from pylabrobot.resources import Plate from pylabrobot.resources.well import Well diff --git a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py b/pylabrobot/legacy/plate_reading/tecan/infinite_backend_tests.py similarity index 99% rename from pylabrobot/plate_reading/tecan/infinite_backend_tests.py rename to pylabrobot/legacy/plate_reading/tecan/infinite_backend_tests.py index 1deb0ccaf8a..b463cebd4fc 100644 --- a/pylabrobot/plate_reading/tecan/infinite_backend_tests.py +++ b/pylabrobot/legacy/plate_reading/tecan/infinite_backend_tests.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, call, patch from pylabrobot.io.usb import USB -from pylabrobot.plate_reading.tecan.infinite_backend import ( +from pylabrobot.legacy.plate_reading.tecan.infinite_backend import ( ExperimentalTecanInfinite200ProBackend, _absorbance_od_calibrated, _AbsorbanceRunDecoder, @@ -653,7 +653,7 @@ def setUp(self): self.mock_usb.read = AsyncMock(return_value=self._frame("ST")) patcher = patch( - "pylabrobot.plate_reading.tecan.infinite_backend.USB", + "pylabrobot.legacy.plate_reading.tecan.infinite_backend.USB", return_value=self.mock_usb, ) self.mock_usb_class = patcher.start() diff --git a/pylabrobot/legacy/plate_reading/tecan/spark20m/__init__.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/__init__.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/__init__.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/__init__.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/__init__.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/base_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/base_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/base_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/base_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/camera_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/camera_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/camera_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/camera_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/config_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/config_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/config_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/config_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/data_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/data_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/data_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/data_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/gas_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/gas_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/gas_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/gas_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/injector_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/injector_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/injector_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/injector_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/measurement_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/measurement_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/measurement_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/measurement_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/movement_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/movement_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/movement_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/movement_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/optics_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/optics_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/optics_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/optics_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/plate_transport_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/plate_transport_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/plate_transport_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/plate_transport_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/sensor_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/sensor_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/sensor_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/sensor_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/spark_enums.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/spark_enums.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/spark_enums.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/spark_enums.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/controls/system_control.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/controls/system_control.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/controls/system_control.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/controls/system_control.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/enums.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/enums.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/enums.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/enums.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_backend.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend.py similarity index 98% rename from pylabrobot/plate_reading/tecan/spark20m/spark_backend.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend.py index e16cfc5f4c7..76b5414591a 100644 --- a/pylabrobot/plate_reading/tecan/spark20m/spark_backend.py +++ b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend.py @@ -3,8 +3,8 @@ import time from typing import Dict, List, Optional -from pylabrobot.plate_reading.backend import PlateReaderBackend -from pylabrobot.plate_reading.utils import _get_min_max_row_col_tuples +from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend +from pylabrobot.legacy.plate_reading.utils import _get_min_max_row_col_tuples from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_backend_tests.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend_tests.py similarity index 90% rename from pylabrobot/plate_reading/tecan/spark20m/spark_backend_tests.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend_tests.py index 441935c4097..75d2c8ba650 100644 --- a/pylabrobot/plate_reading/tecan/spark20m/spark_backend_tests.py +++ b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_backend_tests.py @@ -3,8 +3,8 @@ import unittest from unittest.mock import AsyncMock, MagicMock, patch -from pylabrobot.plate_reading.tecan.spark20m.enums import SparkDevice -from pylabrobot.plate_reading.tecan.spark20m.spark_backend import ExperimentalSparkBackend +from pylabrobot.legacy.plate_reading.tecan.spark20m.enums import SparkDevice +from pylabrobot.legacy.plate_reading.tecan.spark20m.spark_backend import ExperimentalSparkBackend from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well @@ -16,7 +16,7 @@ class TestExperimentalSparkBackend(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: # Patch SparkReaderAsync self.reader_patcher = patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_backend.SparkReaderAsync" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_backend.SparkReaderAsync" ) self.MockReaderClass = self.reader_patcher.start() self.mock_reader = self.MockReaderClass.return_value @@ -27,12 +27,12 @@ async def asyncSetUp(self) -> None: # Patch processor functions self.abs_proc_patcher = patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_backend.process_absorbance" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_backend.process_absorbance" ) self.mock_process_absorbance = self.abs_proc_patcher.start() self.fluo_proc_patcher = patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_backend.process_fluorescence" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_backend.process_fluorescence" ) self.mock_process_fluorescence = self.fluo_proc_patcher.start() diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_packet_parser.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_packet_parser.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/spark_packet_parser.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_packet_parser.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_processor.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_processor.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/spark_processor.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_processor.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_processor_tests.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_processor_tests.py similarity index 96% rename from pylabrobot/plate_reading/tecan/spark20m/spark_processor_tests.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_processor_tests.py index 8eda39ac690..aa34f6f13e0 100644 --- a/pylabrobot/plate_reading/tecan/spark20m/spark_processor_tests.py +++ b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_processor_tests.py @@ -6,7 +6,7 @@ import pytest # Configure logging to avoid pollution during tests -from pylabrobot.plate_reading.tecan.spark20m.spark_processor import ( +from pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor import ( process_absorbance, process_fluorescence, ) @@ -65,7 +65,7 @@ def test_process_success(self) -> None: } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_absorbance([]) @@ -83,7 +83,7 @@ def test_process_missing_reference(self) -> None: # Only standalone sequences, no grouped reference parsed_data = {"SEQ_MEAS": [{"type": "standalone", "block": {"measurements": []}}]} with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_absorbance([]) @@ -92,7 +92,8 @@ def test_process_missing_reference(self) -> None: def test_process_empty_data(self) -> None: with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value={} + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + return_value={}, ): results = process_absorbance([]) self.assertEqual(results, []) @@ -117,7 +118,7 @@ def test_zero_division_protection(self) -> None: } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_absorbance([]) @@ -258,7 +259,7 @@ def test_process_success(self) -> None: } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_fluorescence([]) @@ -277,7 +278,7 @@ def test_process_missing_calibration(self) -> None: ] } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_fluorescence([]) @@ -296,7 +297,7 @@ def test_process_invalid_dark_block(self) -> None: } with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_processor._parse_raw_data", return_value=parsed_data, ): results = process_fluorescence([]) diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_reader_async.py similarity index 100% rename from pylabrobot/plate_reading/tecan/spark20m/spark_reader_async.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_reader_async.py diff --git a/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async_tests.py b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_reader_async_tests.py similarity index 95% rename from pylabrobot/plate_reading/tecan/spark20m/spark_reader_async_tests.py rename to pylabrobot/legacy/plate_reading/tecan/spark20m/spark_reader_async_tests.py index db0bab73ff8..ebbb14c81f8 100644 --- a/pylabrobot/plate_reading/tecan/spark20m/spark_reader_async_tests.py +++ b/pylabrobot/legacy/plate_reading/tecan/spark20m/spark_reader_async_tests.py @@ -8,14 +8,19 @@ pytest.importorskip("usb") # Import the module under test -from pylabrobot.plate_reading.tecan.spark20m.enums import SparkDevice, SparkEndpoint -from pylabrobot.plate_reading.tecan.spark20m.spark_reader_async import SparkError, SparkReaderAsync +from pylabrobot.legacy.plate_reading.tecan.spark20m.enums import SparkDevice, SparkEndpoint +from pylabrobot.legacy.plate_reading.tecan.spark20m.spark_reader_async import ( + SparkError, + SparkReaderAsync, +) class TestSparkReaderAsync(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: # Patch USB class - self.usb_patcher = patch("pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.USB") + self.usb_patcher = patch( + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_reader_async.USB" + ) self.mock_usb_class = self.usb_patcher.start() self.reader = SparkReaderAsync() @@ -138,7 +143,7 @@ async def test_send_command_device_not_connected(self) -> None: async def test_get_response_success(self) -> None: # Mock parse_single_spark_packet with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" ) as mock_parse: mock_parse.return_value = {"type": "RespReady", "payload": {"status": "OK"}} @@ -158,7 +163,7 @@ async def test_get_response_busy_then_ready(self) -> None: mock_reader._read_packet = MagicMock() with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" ) as mock_parse: # Sequence of parse results: # 1. First read (passed as task): RespMessage (busy/intermediate) @@ -269,7 +274,7 @@ async def test_close(self) -> None: async def test_get_response_error(self) -> None: with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" ) as mock_parse: mock_parse.return_value = {"type": "RespError", "payload": {"error": "BadCommand"}} @@ -301,7 +306,7 @@ def execute_sync(func, *args): mock_reader._executor.submit.side_effect = execute_sync with patch( - "pylabrobot.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" + "pylabrobot.legacy.plate_reading.tecan.spark20m.spark_reader_async.parse_single_spark_packet" ) as mock_parse: # Sequence: # 1. First read task returns empty bytes -> Triggers ValueError in parser (mocked below) -> retry diff --git a/pylabrobot/plate_reading/utils.py b/pylabrobot/legacy/plate_reading/utils.py similarity index 100% rename from pylabrobot/plate_reading/utils.py rename to pylabrobot/legacy/plate_reading/utils.py diff --git a/pylabrobot/plate_washing/__init__.py b/pylabrobot/legacy/plate_washing/__init__.py similarity index 100% rename from pylabrobot/plate_washing/__init__.py rename to pylabrobot/legacy/plate_washing/__init__.py diff --git a/pylabrobot/plate_washing/biotek/__init__.py b/pylabrobot/legacy/plate_washing/biotek/__init__.py similarity index 56% rename from pylabrobot/plate_washing/biotek/__init__.py rename to pylabrobot/legacy/plate_washing/biotek/__init__.py index dc8718dd159..db80d4ff823 100644 --- a/pylabrobot/plate_washing/biotek/__init__.py +++ b/pylabrobot/legacy/plate_washing/biotek/__init__.py @@ -1,5 +1,5 @@ """BioTek plate washer backends for PyLabRobot. Import device-specific symbols from subpackages: - from pylabrobot.plate_washing.biotek.el406 import BioTekEL406Backend + from pylabrobot.legacy.plate_washing.biotek.el406 import BioTekEL406Backend """ diff --git a/pylabrobot/plate_washing/biotek/el406/__init__.py b/pylabrobot/legacy/plate_washing/biotek/el406/__init__.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/__init__.py rename to pylabrobot/legacy/plate_washing/biotek/el406/__init__.py diff --git a/pylabrobot/plate_washing/biotek/el406/actions.py b/pylabrobot/legacy/plate_washing/biotek/el406/actions.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/actions.py rename to pylabrobot/legacy/plate_washing/biotek/el406/actions.py diff --git a/pylabrobot/plate_washing/biotek/el406/actions_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/actions_tests.py similarity index 98% rename from pylabrobot/plate_washing/biotek/el406/actions_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/actions_tests.py index 5ba1b29ed9a..8ad9d5656b2 100644 --- a/pylabrobot/plate_washing/biotek/el406/actions_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/actions_tests.py @@ -1,14 +1,14 @@ # mypy: disable-error-code="union-attr,assignment,arg-type" """Tests for BioTek EL406 action methods.""" -from pylabrobot.plate_washing.biotek.el406 import ( +from pylabrobot.legacy.plate_washing.biotek.el406 import ( EL406Motor, EL406MotorHomeType, EL406StepType, EL406WasherManifold, ExperimentalBioTekEL406Backend, ) -from pylabrobot.plate_washing.biotek.el406.mock_tests import EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import EL406TestCase class TestEL406BackendAbort(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/backend.py b/pylabrobot/legacy/plate_washing/biotek/el406/backend.py similarity index 98% rename from pylabrobot/plate_washing/biotek/el406/backend.py rename to pylabrobot/legacy/plate_washing/biotek/el406/backend.py index 15bb6b833f1..8e45acd3414 100644 --- a/pylabrobot/plate_washing/biotek/el406/backend.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/backend.py @@ -19,7 +19,7 @@ from contextlib import asynccontextmanager from pylabrobot.io.ftdi import FTDI -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.resources import Plate from .actions import EL406ActionsMixin diff --git a/pylabrobot/plate_washing/biotek/el406/batch_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/batch_tests.py similarity index 98% rename from pylabrobot/plate_washing/biotek/el406/batch_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/batch_tests.py index b7ef54ec295..988d99664f7 100644 --- a/pylabrobot/plate_washing/biotek/el406/batch_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/batch_tests.py @@ -2,7 +2,7 @@ import unittest -from pylabrobot.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase class TestBatchContextManager(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/communication.py b/pylabrobot/legacy/plate_washing/biotek/el406/communication.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/communication.py rename to pylabrobot/legacy/plate_washing/biotek/el406/communication.py diff --git a/pylabrobot/plate_washing/biotek/el406/communication_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/communication_tests.py similarity index 87% rename from pylabrobot/plate_washing/biotek/el406/communication_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/communication_tests.py index cca36900599..4575264512c 100644 --- a/pylabrobot/plate_washing/biotek/el406/communication_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/communication_tests.py @@ -1,7 +1,7 @@ # mypy: disable-error-code="union-attr,assignment,arg-type" """Tests for BioTek EL406 communication functionality.""" -from pylabrobot.plate_washing.biotek.el406.mock_tests import EL406TestCase, MockFTDI +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import EL406TestCase, MockFTDI class TestTestCommunication(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/enums.py b/pylabrobot/legacy/plate_washing/biotek/el406/enums.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/enums.py rename to pylabrobot/legacy/plate_washing/biotek/el406/enums.py diff --git a/pylabrobot/plate_washing/biotek/el406/error_codes.py b/pylabrobot/legacy/plate_washing/biotek/el406/error_codes.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/error_codes.py rename to pylabrobot/legacy/plate_washing/biotek/el406/error_codes.py diff --git a/pylabrobot/plate_washing/biotek/el406/errors.py b/pylabrobot/legacy/plate_washing/biotek/el406/errors.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/errors.py rename to pylabrobot/legacy/plate_washing/biotek/el406/errors.py diff --git a/pylabrobot/plate_washing/biotek/el406/helpers.py b/pylabrobot/legacy/plate_washing/biotek/el406/helpers.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/helpers.py rename to pylabrobot/legacy/plate_washing/biotek/el406/helpers.py diff --git a/pylabrobot/plate_washing/biotek/el406/helpers_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/helpers_tests.py similarity index 95% rename from pylabrobot/plate_washing/biotek/el406/helpers_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/helpers_tests.py index 9649c3eb73b..b532057aed6 100644 --- a/pylabrobot/plate_washing/biotek/el406/helpers_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/helpers_tests.py @@ -2,16 +2,16 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.helpers import plate_to_wire_byte -from pylabrobot.plate_washing.biotek.el406.mock_tests import ( +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.helpers import plate_to_wire_byte +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import ( PT96, PT384, PT384PCR, PT1536, PT1536F, ) -from pylabrobot.plate_washing.biotek.el406.protocol import encode_column_mask +from pylabrobot.legacy.plate_washing.biotek.el406.protocol import encode_column_mask class TestPlateToWireByte(unittest.TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/mock_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/mock_tests.py similarity index 98% rename from pylabrobot/plate_washing/biotek/el406/mock_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/mock_tests.py index 683e84e46b1..ef4557b167c 100644 --- a/pylabrobot/plate_washing/biotek/el406/mock_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/mock_tests.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend from pylabrobot.resources import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well diff --git a/pylabrobot/plate_washing/biotek/el406/protocol.py b/pylabrobot/legacy/plate_washing/biotek/el406/protocol.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/protocol.py rename to pylabrobot/legacy/plate_washing/biotek/el406/protocol.py diff --git a/pylabrobot/plate_washing/biotek/el406/queries.py b/pylabrobot/legacy/plate_washing/biotek/el406/queries.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/queries.py rename to pylabrobot/legacy/plate_washing/biotek/el406/queries.py diff --git a/pylabrobot/plate_washing/biotek/el406/queries_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/queries_tests.py similarity index 99% rename from pylabrobot/plate_washing/biotek/el406/queries_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/queries_tests.py index 4891605952b..0b2cea0474d 100644 --- a/pylabrobot/plate_washing/biotek/el406/queries_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/queries_tests.py @@ -7,13 +7,13 @@ import unittest # Import the backend module -from pylabrobot.plate_washing.biotek.el406 import ( +from pylabrobot.legacy.plate_washing.biotek.el406 import ( EL406Sensor, EL406SyringeManifold, EL406WasherManifold, ExperimentalBioTekEL406Backend, ) -from pylabrobot.plate_washing.biotek.el406.mock_tests import EL406TestCase, MockFTDI +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import EL406TestCase, MockFTDI class TestEL406BackendGetWasherManifold(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/setup_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/setup_tests.py similarity index 93% rename from pylabrobot/plate_washing/biotek/el406/setup_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/setup_tests.py index e0cd1d0a758..f6fc7999051 100644 --- a/pylabrobot/plate_washing/biotek/el406/setup_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/setup_tests.py @@ -3,11 +3,11 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ( +from pylabrobot.legacy.plate_washing.biotek.el406 import ( EL406CommunicationError, ExperimentalBioTekEL406Backend, ) -from pylabrobot.plate_washing.biotek.el406.mock_tests import EL406TestCase, MockFTDI +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import EL406TestCase, MockFTDI class TestEL406BackendSetup(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/steps/__init__.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/__init__.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/steps/__init__.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps/__init__.py diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_base.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_base.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/steps/_base.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps/_base.py diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_manifold.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_manifold.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/steps/_manifold.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps/_manifold.py diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_peristaltic.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_peristaltic.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/steps/_peristaltic.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps/_peristaltic.py diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_shake.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_shake.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/steps/_shake.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps/_shake.py diff --git a/pylabrobot/plate_washing/biotek/el406/steps/_syringe.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_syringe.py similarity index 100% rename from pylabrobot/plate_washing/biotek/el406/steps/_syringe.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps/_syringe.py diff --git a/pylabrobot/plate_washing/biotek/el406/steps_aspirate_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_aspirate_tests.py similarity index 97% rename from pylabrobot/plate_washing/biotek/el406/steps_aspirate_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps_aspirate_tests.py index 22ef6d92c12..c208cee3de8 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps_aspirate_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_aspirate_tests.py @@ -8,8 +8,8 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase class TestEL406BackendAspirate(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/steps_dispense_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_dispense_tests.py similarity index 98% rename from pylabrobot/plate_washing/biotek/el406/steps_dispense_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps_dispense_tests.py index 917ab1c8a79..3840d040606 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps_dispense_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_dispense_tests.py @@ -3,8 +3,8 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase class TestEL406BackendDispense(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/steps_peristaltic_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_peristaltic_tests.py similarity index 99% rename from pylabrobot/plate_washing/biotek/el406/steps_peristaltic_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps_peristaltic_tests.py index 95ce8c450cb..e6ce2b2d0e9 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps_peristaltic_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_peristaltic_tests.py @@ -8,8 +8,8 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.mock_tests import PT96, PT1536, EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import PT96, PT1536, EL406TestCase class TestEL406BackendPeristalticDispense(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/steps_prime_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_prime_tests.py similarity index 99% rename from pylabrobot/plate_washing/biotek/el406/steps_prime_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps_prime_tests.py index 14c51cc4370..054ce35673b 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps_prime_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_prime_tests.py @@ -11,8 +11,8 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase class TestEL406BackendPeristalticPrime(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/steps_shake_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_shake_tests.py similarity index 97% rename from pylabrobot/plate_washing/biotek/el406/steps_shake_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps_shake_tests.py index d875586f6cc..799ad9d641c 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps_shake_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_shake_tests.py @@ -7,8 +7,8 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import PT96, EL406TestCase class TestEL406BackendShake(EL406TestCase): diff --git a/pylabrobot/plate_washing/biotek/el406/steps_wash_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_wash_tests.py similarity index 99% rename from pylabrobot/plate_washing/biotek/el406/steps_wash_tests.py rename to pylabrobot/legacy/plate_washing/biotek/el406/steps_wash_tests.py index 641a988c1bb..e7a1410d5db 100644 --- a/pylabrobot/plate_washing/biotek/el406/steps_wash_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_wash_tests.py @@ -3,8 +3,8 @@ import unittest -from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend -from pylabrobot.plate_washing.biotek.el406.mock_tests import ( +from pylabrobot.legacy.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend +from pylabrobot.legacy.plate_washing.biotek.el406.mock_tests import ( PT96, PT384, PT384PCR, diff --git a/pylabrobot/powder_dispensing/__init__.py b/pylabrobot/legacy/powder_dispensing/__init__.py similarity index 100% rename from pylabrobot/powder_dispensing/__init__.py rename to pylabrobot/legacy/powder_dispensing/__init__.py diff --git a/pylabrobot/powder_dispensing/backend.py b/pylabrobot/legacy/powder_dispensing/backend.py similarity index 95% rename from pylabrobot/powder_dispensing/backend.py rename to pylabrobot/legacy/powder_dispensing/backend.py index 161bc627318..59ee16a7130 100644 --- a/pylabrobot/powder_dispensing/backend.py +++ b/pylabrobot/legacy/powder_dispensing/backend.py @@ -3,7 +3,7 @@ from abc import ABCMeta, abstractmethod from typing import List -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.resources import Powder, Resource diff --git a/pylabrobot/powder_dispensing/chatterbox.py b/pylabrobot/legacy/powder_dispensing/chatterbox.py similarity index 92% rename from pylabrobot/powder_dispensing/chatterbox.py rename to pylabrobot/legacy/powder_dispensing/chatterbox.py index 6d2d5fa5640..4b4855f90c5 100644 --- a/pylabrobot/powder_dispensing/chatterbox.py +++ b/pylabrobot/legacy/powder_dispensing/chatterbox.py @@ -1,6 +1,6 @@ from typing import List -from pylabrobot.powder_dispensing.backend import ( +from pylabrobot.legacy.powder_dispensing.backend import ( DispenseResults, PowderDispense, PowderDispenserBackend, diff --git a/pylabrobot/powder_dispensing/chemspeed/crystal_powderdose.py b/pylabrobot/legacy/powder_dispensing/chemspeed/crystal_powderdose.py similarity index 90% rename from pylabrobot/powder_dispensing/chemspeed/crystal_powderdose.py rename to pylabrobot/legacy/powder_dispensing/chemspeed/crystal_powderdose.py index 1f2b6a22159..02abed0e6c4 100644 --- a/pylabrobot/powder_dispensing/chemspeed/crystal_powderdose.py +++ b/pylabrobot/legacy/powder_dispensing/chemspeed/crystal_powderdose.py @@ -1,4 +1,4 @@ -from pylabrobot.powder_dispensing.backend import ( +from pylabrobot.legacy.powder_dispensing.backend import ( PowderDispenserBackend, ) diff --git a/pylabrobot/powder_dispensing/powder_dispenser.py b/pylabrobot/legacy/powder_dispensing/powder_dispenser.py similarity index 96% rename from pylabrobot/powder_dispensing/powder_dispenser.py rename to pylabrobot/legacy/powder_dispensing/powder_dispenser.py index a2456bf99de..c48e4af7ebb 100644 --- a/pylabrobot/powder_dispensing/powder_dispenser.py +++ b/pylabrobot/legacy/powder_dispensing/powder_dispenser.py @@ -1,6 +1,6 @@ from typing import Any, Dict, List, Sequence, Union, cast -from pylabrobot.machines.machine import Machine, need_setup_finished +from pylabrobot.legacy.machines.machine import Machine, need_setup_finished from pylabrobot.resources import Powder, Resource from .backend import PowderDispense, PowderDispenserBackend diff --git a/pylabrobot/powder_dispensing/powder_dispenser_tests.py b/pylabrobot/legacy/powder_dispensing/powder_dispenser_tests.py similarity index 95% rename from pylabrobot/powder_dispensing/powder_dispenser_tests.py rename to pylabrobot/legacy/powder_dispensing/powder_dispenser_tests.py index 3e9b410a808..8f65821f506 100644 --- a/pylabrobot/powder_dispensing/powder_dispenser_tests.py +++ b/pylabrobot/legacy/powder_dispensing/powder_dispenser_tests.py @@ -2,12 +2,12 @@ from typing import List from unittest.mock import AsyncMock -from pylabrobot.powder_dispensing.backend import ( +from pylabrobot.legacy.powder_dispensing.backend import ( DispenseResults, PowderDispense, PowderDispenserBackend, ) -from pylabrobot.powder_dispensing.powder_dispenser import ( +from pylabrobot.legacy.powder_dispensing.powder_dispenser import ( PowderDispenser, ) from pylabrobot.resources import Cor_96_wellplate_360ul_Fb, Powder diff --git a/pylabrobot/pumps/__init__.py b/pylabrobot/legacy/pumps/__init__.py similarity index 100% rename from pylabrobot/pumps/__init__.py rename to pylabrobot/legacy/pumps/__init__.py diff --git a/pylabrobot/pumps/agrowpumps/__init__.py b/pylabrobot/legacy/pumps/agrowpumps/__init__.py similarity index 100% rename from pylabrobot/pumps/agrowpumps/__init__.py rename to pylabrobot/legacy/pumps/agrowpumps/__init__.py diff --git a/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py similarity index 99% rename from pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py rename to pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py index 1ba4da80908..605d835a542 100644 --- a/pylabrobot/pumps/agrowpumps/agrowdosepump_backend.py +++ b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py @@ -12,7 +12,7 @@ AsyncModbusSerialClient = None # type: ignore _MODBUS_IMPORT_ERROR = e -from pylabrobot.pumps.backend import PumpArrayBackend +from pylabrobot.legacy.pumps.backend import PumpArrayBackend logger = logging.getLogger("pylabrobot") diff --git a/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py similarity index 96% rename from pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py rename to pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py index b9d6047a0e4..4a46ba87987 100644 --- a/pylabrobot/pumps/agrowpumps/agrowdosepump_tests.py +++ b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py @@ -7,8 +7,8 @@ from pymodbus.client import AsyncModbusSerialClient # type: ignore -from pylabrobot.pumps import PumpArray -from pylabrobot.pumps.agrowpumps import AgrowPumpArrayBackend +from pylabrobot.legacy.pumps import PumpArray +from pylabrobot.legacy.pumps.agrowpumps import AgrowPumpArrayBackend class SimulatedModbusClient(AsyncModbusSerialClient): diff --git a/pylabrobot/pumps/backend.py b/pylabrobot/legacy/pumps/backend.py similarity index 96% rename from pylabrobot/pumps/backend.py rename to pylabrobot/legacy/pumps/backend.py index 3ae2a3a151d..50a3b1a8ce8 100644 --- a/pylabrobot/pumps/backend.py +++ b/pylabrobot/legacy/pumps/backend.py @@ -1,7 +1,7 @@ from abc import ABCMeta, abstractmethod from typing import List -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class PumpBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/pumps/calibration.py b/pylabrobot/legacy/pumps/calibration.py similarity index 100% rename from pylabrobot/pumps/calibration.py rename to pylabrobot/legacy/pumps/calibration.py diff --git a/pylabrobot/pumps/calibration_tests.py b/pylabrobot/legacy/pumps/calibration_tests.py similarity index 98% rename from pylabrobot/pumps/calibration_tests.py rename to pylabrobot/legacy/pumps/calibration_tests.py index 96294937baf..2112f9d6dfc 100644 --- a/pylabrobot/pumps/calibration_tests.py +++ b/pylabrobot/legacy/pumps/calibration_tests.py @@ -2,7 +2,7 @@ import unittest import pylabrobot -from pylabrobot.pumps.calibration import PumpCalibration +from pylabrobot.legacy.pumps.calibration import PumpCalibration plr_directory = os.path.join(pylabrobot.__path__[0], "testing", "test_data") diff --git a/pylabrobot/pumps/chatterbox.py b/pylabrobot/legacy/pumps/chatterbox.py similarity index 94% rename from pylabrobot/pumps/chatterbox.py rename to pylabrobot/legacy/pumps/chatterbox.py index 793792460d2..567ce2f4efc 100644 --- a/pylabrobot/pumps/chatterbox.py +++ b/pylabrobot/legacy/pumps/chatterbox.py @@ -1,6 +1,6 @@ from typing import List -from pylabrobot.pumps.backend import PumpArrayBackend, PumpBackend +from pylabrobot.legacy.pumps.backend import PumpArrayBackend, PumpBackend class PumpChatterboxBackend(PumpBackend): diff --git a/pylabrobot/pumps/cole_parmer/__init__.py b/pylabrobot/legacy/pumps/cole_parmer/__init__.py similarity index 100% rename from pylabrobot/pumps/cole_parmer/__init__.py rename to pylabrobot/legacy/pumps/cole_parmer/__init__.py diff --git a/pylabrobot/pumps/cole_parmer/masterflex_backend.py b/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py similarity index 97% rename from pylabrobot/pumps/cole_parmer/masterflex_backend.py rename to pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py index 41cdf5b24a0..9612f02a024 100644 --- a/pylabrobot/pumps/cole_parmer/masterflex_backend.py +++ b/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py @@ -7,7 +7,7 @@ _SERIAL_IMPORT_ERROR = e from pylabrobot.io.serial import Serial -from pylabrobot.pumps.backend import PumpBackend +from pylabrobot.legacy.pumps.backend import PumpBackend class MasterflexBackend(PumpBackend): diff --git a/pylabrobot/pumps/errors.py b/pylabrobot/legacy/pumps/errors.py similarity index 100% rename from pylabrobot/pumps/errors.py rename to pylabrobot/legacy/pumps/errors.py diff --git a/pylabrobot/pumps/pump.py b/pylabrobot/legacy/pumps/pump.py similarity index 98% rename from pylabrobot/pumps/pump.py rename to pylabrobot/legacy/pumps/pump.py index 9d92f7257ba..5b9d3b31670 100644 --- a/pylabrobot/pumps/pump.py +++ b/pylabrobot/legacy/pumps/pump.py @@ -1,7 +1,7 @@ import asyncio from typing import Optional, Union -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine from .backend import PumpBackend from .calibration import PumpCalibration diff --git a/pylabrobot/pumps/pump_tests.py b/pylabrobot/legacy/pumps/pump_tests.py similarity index 94% rename from pylabrobot/pumps/pump_tests.py rename to pylabrobot/legacy/pumps/pump_tests.py index cc8c3ab7101..71200e0c58d 100644 --- a/pylabrobot/pumps/pump_tests.py +++ b/pylabrobot/legacy/pumps/pump_tests.py @@ -1,11 +1,11 @@ import unittest from unittest.mock import AsyncMock, Mock -from pylabrobot.pumps import PumpArray -from pylabrobot.pumps.backend import PumpArrayBackend, PumpBackend -from pylabrobot.pumps.calibration import PumpCalibration -from pylabrobot.pumps.errors import NotCalibratedError -from pylabrobot.pumps.pump import Pump +from pylabrobot.legacy.pumps import PumpArray +from pylabrobot.legacy.pumps.backend import PumpArrayBackend, PumpBackend +from pylabrobot.legacy.pumps.calibration import PumpCalibration +from pylabrobot.legacy.pumps.errors import NotCalibratedError +from pylabrobot.legacy.pumps.pump import Pump class TestPump(unittest.IsolatedAsyncioTestCase): diff --git a/pylabrobot/pumps/pumparray.py b/pylabrobot/legacy/pumps/pumparray.py similarity index 96% rename from pylabrobot/pumps/pumparray.py rename to pylabrobot/legacy/pumps/pumparray.py index daedb626a46..9d7ca1a5cec 100644 --- a/pylabrobot/pumps/pumparray.py +++ b/pylabrobot/legacy/pumps/pumparray.py @@ -1,10 +1,10 @@ import asyncio from typing import List, Optional, Union -from pylabrobot.machines.machine import Machine -from pylabrobot.pumps.backend import PumpArrayBackend -from pylabrobot.pumps.calibration import PumpCalibration -from pylabrobot.pumps.errors import NotCalibratedError +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.legacy.pumps.backend import PumpArrayBackend +from pylabrobot.legacy.pumps.calibration import PumpCalibration +from pylabrobot.legacy.pumps.errors import NotCalibratedError class PumpArray(Machine): diff --git a/pylabrobot/legacy/scales/__init__.py b/pylabrobot/legacy/scales/__init__.py new file mode 100644 index 00000000000..604e957945f --- /dev/null +++ b/pylabrobot/legacy/scales/__init__.py @@ -0,0 +1,2 @@ +from pylabrobot.legacy.scales.mettler_toledo_backend import MettlerToledoWXS205SDU +from pylabrobot.legacy.scales.scale import Scale diff --git a/pylabrobot/scales/chatterbox.py b/pylabrobot/legacy/scales/chatterbox.py similarity index 89% rename from pylabrobot/scales/chatterbox.py rename to pylabrobot/legacy/scales/chatterbox.py index 7fead273d7c..d33ad741c1e 100644 --- a/pylabrobot/scales/chatterbox.py +++ b/pylabrobot/legacy/scales/chatterbox.py @@ -1,4 +1,4 @@ -from pylabrobot.scales.scale_backend import ScaleBackend +from pylabrobot.legacy.scales.scale_backend import ScaleBackend class ScaleChatterboxBackend(ScaleBackend): diff --git a/pylabrobot/legacy/scales/mettler_toledo_backend.py b/pylabrobot/legacy/scales/mettler_toledo_backend.py new file mode 100644 index 00000000000..e13a65132d3 --- /dev/null +++ b/pylabrobot/legacy/scales/mettler_toledo_backend.py @@ -0,0 +1,116 @@ +"""Legacy. Use pylabrobot.mettler_toledo.MettlerToledoWXS205SDUBackend instead.""" + +import warnings +from typing import List, Literal, Optional, Union + +from pylabrobot.legacy.scales.scale_backend import ScaleBackend +from pylabrobot.mettler_toledo import mettler_toledo as mt + +MettlerToledoError = mt.MettlerToledoError +MettlerToledoResponse = List[str] + + +class MettlerToledoWXS205SDUBackend(ScaleBackend): + """Legacy. Use pylabrobot.mettler_toledo.MettlerToledoWXS205SDUBackend instead.""" + + def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6001): + self._new = mt.MettlerToledoWXS205SDUBackend(port=port, vid=vid, pid=pid) + + async def setup(self) -> None: + await self._new.setup() + + async def stop(self) -> None: + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def zero(self, timeout: Union[Literal["stable"], float, int] = "stable"): + return await self._new.zero(timeout=timeout) + + async def tare(self, timeout: Union[Literal["stable"], float, int] = "stable"): + return await self._new.tare(timeout=timeout) + + async def read_weight(self, timeout: Union[Literal["stable"], float, int] = "stable") -> float: + return await self._new.read_weight(timeout=timeout) + + async def get_weight(self, timeout: Union[Literal["stable"], float, int] = "stable") -> float: + warnings.warn( + "get_weight() is deprecated. Use read_weight() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self._new.read_weight(timeout=timeout) + + async def send_command(self, command: str, timeout: int = 60): + return await self._new.send_command(command=command, timeout=timeout) + + async def request_serial_number(self) -> str: + return await self._new.request_serial_number() + + async def request_tare_weight(self) -> float: + return await self._new.request_tare_weight() + + async def read_stable_weight(self) -> float: + return await self._new.read_stable_weight() + + async def read_dynamic_weight(self, timeout: float) -> float: + return await self._new.read_dynamic_weight(timeout=timeout) + + async def read_weight_value_immediately(self) -> float: + return await self._new.read_weight_value_immediately() + + async def set_display_text(self, text: str): + return await self._new.set_display_text(text=text) + + async def set_weight_display(self): + return await self._new.set_weight_display() + + # Deprecated aliases + + async def get_serial_number(self) -> str: + warnings.warn( + "get_serial_number() is deprecated. Use request_serial_number() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self._new.request_serial_number() + + async def get_tare_weight(self) -> float: + warnings.warn( + "get_tare_weight() is deprecated. Use request_tare_weight() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self._new.request_tare_weight() + + async def get_stable_weight(self) -> float: + warnings.warn( + "get_stable_weight() is deprecated. Use read_stable_weight() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self._new.read_stable_weight() + + async def get_dynamic_weight(self, timeout: float) -> float: + warnings.warn( + "get_dynamic_weight() is deprecated. Use read_dynamic_weight() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self._new.read_dynamic_weight(timeout=timeout) + + async def get_weight_value_immediately(self) -> float: + warnings.warn( + "get_weight_value_immediately() is deprecated. Use read_weight_value_immediately() instead.", + DeprecationWarning, + stacklevel=2, + ) + return await self._new.read_weight_value_immediately() + + +class MettlerToledoWXS205SDU: + def __init__(self, *args, **kwargs): + raise RuntimeError( + "`MettlerToledoWXS205SDU` is deprecated. Please use `MettlerToledoWXS205SDUBackend` instead." + ) diff --git a/pylabrobot/scales/scale.py b/pylabrobot/legacy/scales/scale.py similarity index 89% rename from pylabrobot/scales/scale.py rename to pylabrobot/legacy/scales/scale.py index 5ce786d42ff..1e43ea9a48e 100644 --- a/pylabrobot/scales/scale.py +++ b/pylabrobot/legacy/scales/scale.py @@ -1,8 +1,8 @@ from typing import Optional -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.legacy.scales.scale_backend import ScaleBackend from pylabrobot.resources import Resource, Rotation -from pylabrobot.scales.scale_backend import ScaleBackend class Scale(Resource, Machine): diff --git a/pylabrobot/scales/scale_backend.py b/pylabrobot/legacy/scales/scale_backend.py similarity index 64% rename from pylabrobot/scales/scale_backend.py rename to pylabrobot/legacy/scales/scale_backend.py index 85894aea7c7..0e65e1c4192 100644 --- a/pylabrobot/scales/scale_backend.py +++ b/pylabrobot/legacy/scales/scale_backend.py @@ -1,6 +1,8 @@ +"""Legacy. Use pylabrobot.capabilities.weighing.ScaleBackend instead.""" + from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class ScaleBackend(MachineBackend, metaclass=ABCMeta): @@ -17,15 +19,12 @@ async def read_weight(self) -> float: """Read the weight in grams""" ... - # Deprecated: for backward compatibility async def get_weight(self) -> float: - """Deprecated: Use read_weight() instead. - - Get the weight in grams""" + """Deprecated: Use read_weight() instead.""" import warnings warnings.warn( - "get_weight() is deprecated and will be removed in 2026-03. Use read_weight() instead.", + "get_weight() is deprecated. Use read_weight() instead.", DeprecationWarning, stacklevel=2, ) diff --git a/pylabrobot/sealing/__init__.py b/pylabrobot/legacy/sealing/__init__.py similarity index 100% rename from pylabrobot/sealing/__init__.py rename to pylabrobot/legacy/sealing/__init__.py diff --git a/pylabrobot/sealing/a4s.py b/pylabrobot/legacy/sealing/a4s.py similarity index 65% rename from pylabrobot/sealing/a4s.py rename to pylabrobot/legacy/sealing/a4s.py index ac34908c187..270cc0c5b65 100644 --- a/pylabrobot/sealing/a4s.py +++ b/pylabrobot/legacy/sealing/a4s.py @@ -1,5 +1,5 @@ -from pylabrobot.sealing.a4s_backend import A4SBackend -from pylabrobot.sealing.sealer import Sealer +from pylabrobot.legacy.sealing.a4s_backend import A4SBackend +from pylabrobot.legacy.sealing.sealer import Sealer def a4s(port: str) -> Sealer: diff --git a/pylabrobot/legacy/sealing/a4s_backend.py b/pylabrobot/legacy/sealing/a4s_backend.py new file mode 100644 index 00000000000..e4073efb0eb --- /dev/null +++ b/pylabrobot/legacy/sealing/a4s_backend.py @@ -0,0 +1,50 @@ +"""Legacy. Use pylabrobot.azenta.A4SBackend instead.""" + +from pylabrobot.azenta import a4s +from pylabrobot.legacy.sealing.backend import SealerBackend + + +class A4SBackend(SealerBackend): + """Legacy. Use pylabrobot.azenta.A4SBackend instead.""" + + def __init__(self, port: str, timeout: int = 20): + self._new = a4s.A4SBackend(port=port, timeout=timeout) + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def seal(self, temperature: int, duration: float): + await self._new.seal(temperature=temperature, duration=duration) + + async def open(self): + return await self._new.open() + + async def close(self): + return await self._new.close() + + async def set_temperature(self, temperature: float): + await self._new.set_temperature(temperature=temperature) + + async def get_temperature(self) -> float: + return await self._new.get_current_temperature() + + async def set_heater(self, on: bool): + await self._new.set_heater(on=on) + + async def system_reset(self): + await self._new.system_reset() + + async def set_time(self, seconds: float): + await self._new.set_time(seconds=seconds) + + async def get_remaining_time(self) -> int: + return await self._new.get_remaining_time() + + async def get_status(self): + return await self._new.get_status() diff --git a/pylabrobot/sealing/backend.py b/pylabrobot/legacy/sealing/backend.py similarity index 90% rename from pylabrobot/sealing/backend.py rename to pylabrobot/legacy/sealing/backend.py index 0e9e8c01b45..d4addfac09e 100644 --- a/pylabrobot/sealing/backend.py +++ b/pylabrobot/legacy/sealing/backend.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class SealerBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/legacy/sealing/sealer.py b/pylabrobot/legacy/sealing/sealer.py new file mode 100644 index 00000000000..51dd1f3db0e --- /dev/null +++ b/pylabrobot/legacy/sealing/sealer.py @@ -0,0 +1,28 @@ +"""Legacy. Use pylabrobot.azenta.A4S instead.""" + +from pylabrobot.legacy.machines import Machine + +from .backend import SealerBackend + + +class Sealer(Machine): + """Legacy. Use pylabrobot.azenta.A4S instead.""" + + def __init__(self, backend: SealerBackend): + super().__init__(backend=backend) + self._backend: SealerBackend = backend + + async def seal(self, temperature: int, duration: float): + return await self._backend.seal(temperature=temperature, duration=duration) + + async def open(self): + return await self._backend.open() + + async def close(self): + return await self._backend.close() + + async def set_temperature(self, temperature: float): + return await self._backend.set_temperature(temperature=temperature) + + async def get_temperature(self) -> float: + return await self._backend.get_temperature() diff --git a/pylabrobot/shaking/__init__.py b/pylabrobot/legacy/shaking/__init__.py similarity index 100% rename from pylabrobot/shaking/__init__.py rename to pylabrobot/legacy/shaking/__init__.py diff --git a/pylabrobot/shaking/backend.py b/pylabrobot/legacy/shaking/backend.py similarity index 83% rename from pylabrobot/shaking/backend.py rename to pylabrobot/legacy/shaking/backend.py index 23471c6c87b..f16da16a390 100644 --- a/pylabrobot/shaking/backend.py +++ b/pylabrobot/legacy/shaking/backend.py @@ -1,6 +1,8 @@ +"""Legacy. Use pylabrobot.capabilities.shaking.ShakerBackend instead.""" + from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class ShakerBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/shaking/chatterbox.py b/pylabrobot/legacy/shaking/chatterbox.py similarity index 91% rename from pylabrobot/shaking/chatterbox.py rename to pylabrobot/legacy/shaking/chatterbox.py index 8fcfc2933f7..c188aa2fba2 100644 --- a/pylabrobot/shaking/chatterbox.py +++ b/pylabrobot/legacy/shaking/chatterbox.py @@ -1,4 +1,4 @@ -from pylabrobot.shaking import ShakerBackend +from pylabrobot.legacy.shaking import ShakerBackend class ShakerChatterboxBackend(ShakerBackend): diff --git a/pylabrobot/legacy/shaking/shaker.py b/pylabrobot/legacy/shaking/shaker.py new file mode 100644 index 00000000000..24387b92a48 --- /dev/null +++ b/pylabrobot/legacy/shaking/shaker.py @@ -0,0 +1,90 @@ +from typing import Optional + +from pylabrobot.capabilities.shaking import ShakerBackend as _NewShakerBackend +from pylabrobot.capabilities.shaking import ShakingCapability +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.resources import Coordinate, ResourceHolder + +from .backend import ShakerBackend + + +class _ShakingAdapter(_NewShakerBackend): + def __init__(self, legacy: ShakerBackend): + self._legacy = legacy + + async def setup(self): + pass + + async def stop(self): + pass + + async def start_shaking(self, speed: float): + await self._legacy.start_shaking(speed) + + async def stop_shaking(self): + await self._legacy.stop_shaking() + + @property + def supports_locking(self) -> bool: + return self._legacy.supports_locking + + async def lock_plate(self): + await self._legacy.lock_plate() + + async def unlock_plate(self): + await self._legacy.unlock_plate() + + +class Shaker(ResourceHolder, Machine): + """Legacy. Use a vendor-specific machine with ShakingCapability instead.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: ShakerBackend, + child_location: Coordinate, + category: str = "shaker", + model: Optional[str] = None, + ): + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + category=category, + model=model, + child_location=child_location, + ) + Machine.__init__(self, backend=backend) + self.backend: ShakerBackend = backend + self._shaking_cap = ShakingCapability(backend=_ShakingAdapter(backend)) + + async def setup(self, **backend_kwargs): + await super().setup(**backend_kwargs) + await self._shaking_cap._on_setup() + + async def shake(self, speed: float, duration: Optional[float] = None, **backend_kwargs): + return await self._shaking_cap.shake(speed=speed, duration=duration) + + async def stop_shaking(self, **backend_kwargs): + await self._shaking_cap.stop_shaking() + + async def lock_plate(self, **backend_kwargs): + await self._shaking_cap.lock_plate() + + async def unlock_plate(self, **backend_kwargs): + await self._shaking_cap.unlock_plate() + + async def stop(self): + await self._shaking_cap._on_stop() + await super().stop() + + def serialize(self) -> dict: + return { + **Machine.serialize(self), + **ResourceHolder.serialize(self), + } diff --git a/pylabrobot/shaking/shaker_tests.py b/pylabrobot/legacy/shaking/shaker_tests.py similarity index 86% rename from pylabrobot/shaking/shaker_tests.py rename to pylabrobot/legacy/shaking/shaker_tests.py index acea10a9676..4a1f391a4ca 100644 --- a/pylabrobot/shaking/shaker_tests.py +++ b/pylabrobot/legacy/shaking/shaker_tests.py @@ -1,7 +1,7 @@ import unittest +from pylabrobot.legacy.shaking import Shaker, ShakerChatterboxBackend from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.shaking import Shaker, ShakerChatterboxBackend class ShakerTests(unittest.TestCase): diff --git a/pylabrobot/storage/__init__.py b/pylabrobot/legacy/storage/__init__.py similarity index 100% rename from pylabrobot/storage/__init__.py rename to pylabrobot/legacy/storage/__init__.py diff --git a/pylabrobot/storage/backend.py b/pylabrobot/legacy/storage/backend.py similarity index 95% rename from pylabrobot/storage/backend.py rename to pylabrobot/legacy/storage/backend.py index 82fb094dc62..7f9043bad78 100644 --- a/pylabrobot/storage/backend.py +++ b/pylabrobot/legacy/storage/backend.py @@ -1,7 +1,7 @@ from abc import ABCMeta, abstractmethod from typing import List, Optional -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.resources import Plate, PlateCarrier, PlateHolder diff --git a/pylabrobot/storage/chatterbox.py b/pylabrobot/legacy/storage/chatterbox.py similarity index 94% rename from pylabrobot/storage/chatterbox.py rename to pylabrobot/legacy/storage/chatterbox.py index 89115098f00..2280f7cbb8a 100644 --- a/pylabrobot/storage/chatterbox.py +++ b/pylabrobot/legacy/storage/chatterbox.py @@ -1,6 +1,6 @@ +from pylabrobot.legacy.storage.backend import IncubatorBackend from pylabrobot.resources.carrier import PlateHolder from pylabrobot.resources.plate import Plate -from pylabrobot.storage.backend import IncubatorBackend class IncubatorChatterboxBackend(IncubatorBackend): diff --git a/pylabrobot/legacy/storage/cytomat/__init__.py b/pylabrobot/legacy/storage/cytomat/__init__.py new file mode 100644 index 00000000000..5c84ac1433b --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/__init__.py @@ -0,0 +1,2 @@ +from .constants import CytomatType +from .cytomat import CytomatBackend, CytomatChatterbox diff --git a/pylabrobot/legacy/storage/cytomat/constants.py b/pylabrobot/legacy/storage/cytomat/constants.py new file mode 100644 index 00000000000..276b6986fd8 --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/constants.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.constants instead.""" + +from pylabrobot.thermo_fisher.cytomat.constants import * # noqa: F401,F403 diff --git a/pylabrobot/legacy/storage/cytomat/cytomat.py b/pylabrobot/legacy/storage/cytomat/cytomat.py new file mode 100644 index 00000000000..2eaf0f27581 --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/cytomat.py @@ -0,0 +1,175 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.CytomatBackend instead.""" + +from typing import List, Optional, Union + +from pylabrobot.legacy.storage.backend import IncubatorBackend +from pylabrobot.legacy.storage.cytomat.constants import CytomatType +from pylabrobot.resources import Plate, PlateCarrier, PlateHolder +from pylabrobot.thermo_fisher.cytomat import backend as new_cytomat + + +class CytomatBackend(IncubatorBackend): + """Legacy. Use pylabrobot.thermo_fisher.cytomat.CytomatBackend instead.""" + + def __init__(self, model: Union[CytomatType, str], port: str): + super().__init__() + self._new = new_cytomat.CytomatBackend(model=model, port=port) + + @property + def model(self): + return self._new.model + + @property + def io(self): + return self._new.io + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + async def set_racks(self, racks: List[PlateCarrier]): + await super().set_racks(racks) + await self._new.set_racks(racks) + + async def open_door(self): + return await self._new.open_door() + + async def close_door(self): + return await self._new.close_door() + + async def fetch_plate_to_loading_tray(self, plate: Plate, **backend_kwargs): + await self._new.fetch_plate_to_loading_tray(plate) + + async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs): + await self._new.store_plate(plate, site) + + async def set_temperature(self, *args, **kwargs): + return await self._new.set_temperature(*args, **kwargs) + + async def get_temperature(self) -> float: + return await self._new.get_current_temperature() + + async def start_shaking(self, frequency: float, shakers: Optional[List[int]] = None): + return await self._new.start_shaking(speed=frequency, shakers=shakers) + + async def stop_shaking(self): + return await self._new.stop_shaking() + + # Device-specific methods delegated to new backend + + def _assemble_command(self, command_type: str, command: str, params: str): + return self._new._assemble_command(command_type, command, params) + + async def send_command(self, command_type: str, command: str, params: str) -> str: + return await self._new.send_command(command_type, command, params) + + async def send_action(self, command_type, command, params, timeout=60): + return await self._new.send_action(command_type, command, params, timeout=timeout) + + async def get_overview_register(self): + return await self._new.get_overview_register() + + async def get_warning_register(self): + return await self._new.get_warning_register() + + async def get_error_register(self): + return await self._new.get_error_register() + + async def reset_error_register(self): + return await self._new.reset_error_register() + + async def initialize(self): + return await self._new.initialize() + + async def shovel_in(self): + return await self._new.shovel_in() + + async def shovel_out(self): + return await self._new.shovel_out() + + async def get_action_register(self): + return await self._new.get_action_register() + + async def get_swap_register(self): + return await self._new.get_swap_register() + + async def get_sensor_register(self): + return await self._new.get_sensor_register() + + async def action_transfer_to_storage(self, site): + return await self._new.action_transfer_to_storage(site) + + async def action_storage_to_transfer(self, site): + return await self._new.action_storage_to_transfer(site) + + async def action_storage_to_wait(self, site): + return await self._new.action_storage_to_wait(site) + + async def action_wait_to_storage(self, site): + return await self._new.action_wait_to_storage(site) + + async def action_wait_to_transfer(self): + return await self._new.action_wait_to_transfer() + + async def action_transfer_to_wait(self): + return await self._new.action_transfer_to_wait() + + async def action_wait_to_exposed(self): + return await self._new.action_wait_to_exposed() + + async def action_exposed_to_wait(self): + return await self._new.action_exposed_to_wait() + + async def action_exposed_to_storage(self, site): + return await self._new.action_exposed_to_storage(site) + + async def action_storage_to_exposed(self, site): + return await self._new.action_storage_to_exposed(site) + + async def action_read_barcode(self, site_number_a, site_number_b): + return await self._new.action_read_barcode(site_number_a, site_number_b) + + async def wait_for_transfer_station(self, occupied=False): + return await self._new.wait_for_transfer_station(occupied=occupied) + + async def wait_for_task_completion(self, timeout=60): + return await self._new.wait_for_task_completion(timeout=timeout) + + async def init_shakers(self): + return await self._new.init_shakers() + + async def set_shaking_frequency(self, frequency, shakers=None): + return await self._new.set_shaking_frequency(frequency, shakers) + + async def get_incubation_query(self, query): + return await self._new.get_incubation_query(query) + + async def get_co2(self): + return await self._new.get_co2() + + async def get_humidity(self): + return await self._new.get_humidity() + + async def get_o2(self): + return await self._new.get_o2() + + def serialize(self) -> dict: + return self._new.serialize() + + +class CytomatChatterbox(CytomatBackend): + """Legacy. Use pylabrobot.thermo_fisher.cytomat.CytomatChatterbox instead.""" + + def __init__(self, model: Union[CytomatType, str], port: str): + # Skip CytomatBackend.__init__ and use the new chatterbox directly + IncubatorBackend.__init__(self) + from pylabrobot.thermo_fisher.cytomat.chatterbox import CytomatChatterbox as NewChatterbox + + self._new = NewChatterbox(model=model, port=port) + + +class Cytomat: + def __init__(self, *args, **kwargs): + raise RuntimeError("`Cytomat` is deprecated. Please use `CytomatBackend` instead. ") diff --git a/pylabrobot/legacy/storage/cytomat/errors.py b/pylabrobot/legacy/storage/cytomat/errors.py new file mode 100644 index 00000000000..76fc9c64240 --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/errors.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.errors instead.""" + +from pylabrobot.thermo_fisher.cytomat.errors import * # noqa: F401,F403 diff --git a/pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py b/pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py new file mode 100644 index 00000000000..57a67bae1bc --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py @@ -0,0 +1,67 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.HeraeusCytomatBackend instead.""" + +from typing import List + +from pylabrobot.legacy.storage.backend import IncubatorBackend +from pylabrobot.resources import Plate, PlateHolder +from pylabrobot.resources.carrier import PlateCarrier +from pylabrobot.thermo_fisher.cytomat import heraeus_backend as new_heraeus + + +class HeraeusCytomatBackend(IncubatorBackend): + """Legacy. Use pylabrobot.thermo_fisher.cytomat.HeraeusCytomatBackend instead.""" + + def __init__(self, port: str): + super().__init__() + self._new = new_heraeus.HeraeusCytomatBackend(port=port) + + @property + def io(self): + return self._new.io + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + async def set_racks(self, racks: List[PlateCarrier]): + await super().set_racks(racks) + await self._new.set_racks(racks) + + async def open_door(self): + await self._new.open_door() + + async def close_door(self): + await self._new.close_door() + + async def fetch_plate_to_loading_tray(self, plate: Plate, **backend_kwargs): + await self._new.fetch_plate_to_loading_tray(plate) + + async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs): + await self._new.store_plate(plate, site) + + async def set_temperature(self, temperature: float): + return await self._new.set_temperature(temperature) + + async def get_temperature(self) -> float: + return await self._new.get_current_temperature() + + async def start_shaking(self, frequency: float = 1.0): + await self._new.start_shaking(speed=frequency) + + async def stop_shaking(self): + await self._new.stop_shaking() + + async def wait_for_transfer_station(self, occupied: bool = False): + await self._new.wait_for_transfer_station(occupied=occupied) + + async def initialize(self): + await self._new.initialize() + + def serialize(self) -> dict: + return self._new.serialize() + + @classmethod + def deserialize(cls, data: dict): + return cls(port=data["port"]) diff --git a/pylabrobot/legacy/storage/cytomat/racks.py b/pylabrobot/legacy/storage/cytomat/racks.py new file mode 100644 index 00000000000..db5fa8f1160 --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/racks.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.racks instead.""" + +from pylabrobot.thermo_fisher.cytomat.racks import * # noqa: F401,F403 diff --git a/pylabrobot/legacy/storage/cytomat/schemas.py b/pylabrobot/legacy/storage/cytomat/schemas.py new file mode 100644 index 00000000000..a911589db0e --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/schemas.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.schemas instead.""" + +from pylabrobot.thermo_fisher.cytomat.schemas import * # noqa: F401,F403 diff --git a/pylabrobot/legacy/storage/cytomat/utils.py b/pylabrobot/legacy/storage/cytomat/utils.py new file mode 100644 index 00000000000..94661c4c05e --- /dev/null +++ b/pylabrobot/legacy/storage/cytomat/utils.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.thermo_fisher.cytomat.utils instead.""" + +from pylabrobot.thermo_fisher.cytomat.utils import * # noqa: F401,F403 diff --git a/pylabrobot/storage/incubator.py b/pylabrobot/legacy/storage/incubator.py similarity index 99% rename from pylabrobot/storage/incubator.py rename to pylabrobot/legacy/storage/incubator.py index 4a4d6d5fe64..26b207a3ce2 100644 --- a/pylabrobot/storage/incubator.py +++ b/pylabrobot/legacy/storage/incubator.py @@ -1,7 +1,7 @@ import random from typing import List, Literal, Optional, Union, cast -from pylabrobot.machines import Machine +from pylabrobot.legacy.machines import Machine from pylabrobot.resources import ( Coordinate, Plate, diff --git a/pylabrobot/storage/incubator_tests.py b/pylabrobot/legacy/storage/incubator_tests.py similarity index 86% rename from pylabrobot/storage/incubator_tests.py rename to pylabrobot/legacy/storage/incubator_tests.py index eee9b4873d5..7a7ae988d01 100644 --- a/pylabrobot/storage/incubator_tests.py +++ b/pylabrobot/legacy/storage/incubator_tests.py @@ -1,7 +1,7 @@ import unittest +from pylabrobot.legacy.storage import Incubator, IncubatorChatterboxBackend from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.storage import Incubator, IncubatorChatterboxBackend class IncubatorTests(unittest.TestCase): diff --git a/pylabrobot/storage/inheco/__init__.py b/pylabrobot/legacy/storage/inheco/__init__.py similarity index 100% rename from pylabrobot/storage/inheco/__init__.py rename to pylabrobot/legacy/storage/inheco/__init__.py diff --git a/pylabrobot/storage/inheco/incubator_shaker.py b/pylabrobot/legacy/storage/inheco/incubator_shaker.py similarity index 99% rename from pylabrobot/storage/inheco/incubator_shaker.py rename to pylabrobot/legacy/storage/inheco/incubator_shaker.py index 4b6c0f0d389..f806f491355 100644 --- a/pylabrobot/storage/inheco/incubator_shaker.py +++ b/pylabrobot/legacy/storage/inheco/incubator_shaker.py @@ -1,6 +1,6 @@ from typing import Dict -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine from pylabrobot.resources import Coordinate, Resource, ResourceHolder from .incubator_shaker_backend import InhecoIncubatorShakerStackBackend, InhecoIncubatorShakerUnit diff --git a/pylabrobot/storage/inheco/incubator_shaker_backend.py b/pylabrobot/legacy/storage/inheco/incubator_shaker_backend.py similarity index 99% rename from pylabrobot/storage/inheco/incubator_shaker_backend.py rename to pylabrobot/legacy/storage/inheco/incubator_shaker_backend.py index 2d4a7ace352..2627b7ae797 100644 --- a/pylabrobot/storage/inheco/incubator_shaker_backend.py +++ b/pylabrobot/legacy/storage/inheco/incubator_shaker_backend.py @@ -21,7 +21,7 @@ from typing import Awaitable, Callable, Dict, List, Literal, Optional, TypeVar, cast from pylabrobot.io.serial import Serial -from pylabrobot.machines.machine import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend if sys.version_info < (3, 10): from typing_extensions import Concatenate, ParamSpec diff --git a/pylabrobot/storage/inheco/scila/__init__.py b/pylabrobot/legacy/storage/inheco/scila/__init__.py similarity index 100% rename from pylabrobot/storage/inheco/scila/__init__.py rename to pylabrobot/legacy/storage/inheco/scila/__init__.py diff --git a/pylabrobot/legacy/storage/inheco/scila/inheco_sila_interface.py b/pylabrobot/legacy/storage/inheco/scila/inheco_sila_interface.py new file mode 100644 index 00000000000..29d7d7c05a7 --- /dev/null +++ b/pylabrobot/legacy/storage/inheco/scila/inheco_sila_interface.py @@ -0,0 +1,6 @@ +"""Legacy. Use pylabrobot.inheco.scila.InhecoSiLAInterface instead.""" + +from pylabrobot.inheco.scila.inheco_sila_interface import ( # noqa: F401 + InhecoSiLAInterface, + SiLAError, +) diff --git a/pylabrobot/legacy/storage/inheco/scila/scila_backend.py b/pylabrobot/legacy/storage/inheco/scila/scila_backend.py new file mode 100644 index 00000000000..dd80c5d8294 --- /dev/null +++ b/pylabrobot/legacy/storage/inheco/scila/scila_backend.py @@ -0,0 +1,74 @@ +"""Legacy. Use pylabrobot.inheco.scila.SCILABackend instead.""" + +from typing import Any, Dict, Literal, Optional + +from pylabrobot.inheco.scila import scila_backend as new_scila +from pylabrobot.legacy.machines.backend import MachineBackend + +DrawerStatus = Literal["Opened", "Closed"] + + +class SCILABackend(MachineBackend): + """Legacy. Use pylabrobot.inheco.scila.SCILABackend instead.""" + + def __init__(self, scila_ip: str, client_ip: Optional[str] = None) -> None: + self._new = new_scila.SCILABackend(scila_ip=scila_ip, client_ip=client_ip) + + @property + def _sila_interface(self): + return self._new._sila_interface + + async def setup(self) -> None: + await self._new.setup() + + async def stop(self) -> None: + await self._new.stop() + + async def request_status(self) -> str: + return await self._new.request_status() + + async def request_liquid_level(self) -> str: + return await self._new.request_liquid_level() + + async def request_temperature_information(self) -> dict[str, Any]: + return await self._new.request_temperature_information() + + async def measure_temperature(self) -> float: + return await self._new.get_current_temperature() + + async def request_target_temperature(self) -> float: + return await self._new.request_target_temperature() + + async def is_temperature_control_enabled(self) -> bool: + return await self._new.is_temperature_control_enabled() + + async def open(self, drawer_id: int) -> None: + await self._new.open(drawer_id=drawer_id) + + async def close(self, drawer_id: int) -> None: + await self._new.close(drawer_id=drawer_id) + + async def request_drawer_statuses(self) -> Dict[int, DrawerStatus]: + return await self._new.request_drawer_statuses() + + async def request_drawer_status(self, drawer_id: int) -> DrawerStatus: + return await self._new.request_drawer_status(drawer_id=drawer_id) + + async def request_co2_flow_status(self) -> str: + return await self._new.request_co2_flow_status() + + async def request_valve_status(self) -> dict[str, str]: + return await self._new.request_valve_status() + + async def start_temperature_control(self, temperature: float) -> None: + await self._new.set_temperature(temperature=temperature) + + async def stop_temperature_control(self) -> None: + await self._new.deactivate() + + def serialize(self) -> dict[str, Any]: + return self._new.serialize() + + @classmethod + def deserialize(cls, data: dict[str, Any]) -> "SCILABackend": + return cls(scila_ip=data["scila_ip"], client_ip=data.get("client_ip")) diff --git a/pylabrobot/storage/inheco/scila/scila_backend_tests.py b/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py similarity index 97% rename from pylabrobot/storage/inheco/scila/scila_backend_tests.py rename to pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py index 03cad4dd5f7..448a5301ea3 100644 --- a/pylabrobot/storage/inheco/scila/scila_backend_tests.py +++ b/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py @@ -2,13 +2,13 @@ import xml.etree.ElementTree as ET from unittest.mock import AsyncMock, patch -from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface -from pylabrobot.storage.inheco.scila.scila_backend import SCILABackend +from pylabrobot.inheco.scila.inheco_sila_interface import InhecoSiLAInterface +from pylabrobot.legacy.storage.inheco.scila.scila_backend import SCILABackend class TestSCILABackend(unittest.IsolatedAsyncioTestCase): def setUp(self): - self.patcher = patch("pylabrobot.storage.inheco.scila.scila_backend.InhecoSiLAInterface") + self.patcher = patch("pylabrobot.inheco.scila.scila_backend.InhecoSiLAInterface") self.MockInhecoSiLAInterface = self.patcher.start() self.mock_sila_interface = AsyncMock(spec=InhecoSiLAInterface) self.mock_sila_interface.bound_port = 80 diff --git a/pylabrobot/legacy/storage/inheco/scila/soap.py b/pylabrobot/legacy/storage/inheco/scila/soap.py new file mode 100644 index 00000000000..85befb6c908 --- /dev/null +++ b/pylabrobot/legacy/storage/inheco/scila/soap.py @@ -0,0 +1,4 @@ +"""Legacy. Use pylabrobot.inheco.scila.soap instead.""" + +from pylabrobot.inheco.scila.soap import * # noqa: F401, F403 +from pylabrobot.inheco.scila.soap import XSI # noqa: F401 diff --git a/pylabrobot/storage/liconic/__init__.py b/pylabrobot/legacy/storage/liconic/__init__.py similarity index 100% rename from pylabrobot/storage/liconic/__init__.py rename to pylabrobot/legacy/storage/liconic/__init__.py diff --git a/pylabrobot/legacy/storage/liconic/constants.py b/pylabrobot/legacy/storage/liconic/constants.py new file mode 100644 index 00000000000..83dfc20f554 --- /dev/null +++ b/pylabrobot/legacy/storage/liconic/constants.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.liconic.constants instead.""" + +from pylabrobot.liconic.constants import * # noqa: F401,F403 diff --git a/pylabrobot/legacy/storage/liconic/errors.py b/pylabrobot/legacy/storage/liconic/errors.py new file mode 100644 index 00000000000..d125b31b0e6 --- /dev/null +++ b/pylabrobot/legacy/storage/liconic/errors.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.liconic.errors instead.""" + +from pylabrobot.liconic.errors import * # noqa: F401,F403 diff --git a/pylabrobot/legacy/storage/liconic/liconic_backend.py b/pylabrobot/legacy/storage/liconic/liconic_backend.py new file mode 100644 index 00000000000..78934a9f533 --- /dev/null +++ b/pylabrobot/legacy/storage/liconic/liconic_backend.py @@ -0,0 +1,268 @@ +"""Legacy. Use pylabrobot.liconic.LiconicBackend instead.""" + +from typing import List, Union + +from pylabrobot.capabilities.barcode_scanning.backend import ( + BarcodeScannerBackend as _NewBarcodeScannerBackend, +) +from pylabrobot.legacy.storage.backend import IncubatorBackend +from pylabrobot.liconic import backend as new_liconic +from pylabrobot.resources import Plate, PlateHolder +from pylabrobot.resources.barcode import Barcode +from pylabrobot.resources.carrier import PlateCarrier + +# Re-export for legacy imports +LICONIC_SITE_HEIGHT_TO_STEPS = new_liconic.LICONIC_SITE_HEIGHT_TO_STEPS + + +class _BarcodeScannerAdapter(_NewBarcodeScannerBackend): + """Adapts a legacy BarcodeScanner Machine to the new BarcodeScannerBackend interface.""" + + def __init__(self, legacy_scanner): + self._legacy = legacy_scanner + + async def setup(self): + pass + + async def stop(self): + pass + + async def scan_barcode(self) -> Barcode: + result: Barcode = await self._legacy.scan() + return result + + +class ExperimentalLiconicBackend(IncubatorBackend): + """Legacy. Use pylabrobot.liconic.LiconicBackend instead.""" + + # Internal attributes that should be forwarded to self._new for test compatibility + _FORWARDED_ATTRS = { + "_send_command", + "_wait_ready", + "_wait_plate_ready", + "_carrier_to_steps_pos", + "_site_to_m_n", + "_racks", + "io", + } + + def __init__( + self, + model: Union["new_liconic.LiconicType", str], + port: str, + barcode_scanner=None, + ): + super().__init__() + self._new = new_liconic.LiconicBackend(model=model, port=port) + self.barcode_scanner = barcode_scanner + + @property + def _barcode_adapter(self) -> _BarcodeScannerAdapter: + """Wrap the legacy BarcodeScanner Machine for the new backend interface.""" + assert self.barcode_scanner is not None, "Barcode scanner not configured" + return _BarcodeScannerAdapter(self.barcode_scanner) + + def __getattr__(self, name): + if name in self._FORWARDED_ATTRS: + return getattr(self._new, name) + raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'") + + def __setattr__(self, name, value): + if name != "_new" and hasattr(self, "_new") and name in self._FORWARDED_ATTRS: + setattr(self._new, name, value) + else: + super().__setattr__(name, value) + + @property + def model(self): + return self._new.model + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + async def set_racks(self, racks: List[PlateCarrier]): + await super().set_racks(racks) + await self._new.set_racks(racks) + + async def initialize(self): + await self._new.initialize() + + async def open_door(self): + await self._new.open_door() + + async def close_door(self): + await self._new.close_door() + + async def fetch_plate_to_loading_tray( + self, plate: Plate, read_barcode: bool = False, **backend_kwargs + ): + if not read_barcode: + await self._new.fetch_plate_to_loading_tray(plate) + return + + # Can't use the bundled fetch method because the original interleaves barcode reading + # between DM writes and ST 1905. Replicate the original command sequence. + if self.barcode_scanner is None: + raise RuntimeError("Barcode scanner not configured for this incubator instance") + site = plate.parent + assert isinstance(site, PlateHolder) + m, n = self._new._site_to_m_n(site) + step_size, pos_num = self._new._carrier_to_steps_pos(site) + + await self._new._send_command(f"WR DM0 {m}") + await self._new._send_command(f"WR DM23 {step_size}") + await self._new._send_command(f"WR DM25 {pos_num}") + await self._new._send_command(f"WR DM5 {n}") + + plate.barcode = await self._new.read_barcode_inline(m, n, self._barcode_adapter) + + await self._new._send_command("ST 1905") + await self._new._wait_ready() + await self._new._send_command("ST 1903") + + async def take_in_plate( + self, plate: Plate, site: PlateHolder, read_barcode: bool = False, **backend_kwargs + ): + if not read_barcode: + await self._new.store_plate(plate, site) + return + + # Can't use the bundled store method because the original reads barcode + # AFTER ST 1904/wait but BEFORE ST 1903 (terminate access). + if self.barcode_scanner is None: + raise RuntimeError("Barcode scanner not configured for this incubator instance") + m, n = self._new._site_to_m_n(site) + step_size, pos_num = self._new._carrier_to_steps_pos(site) + + await self._new._send_command(f"WR DM0 {m}") + await self._new._send_command(f"WR DM23 {step_size}") + await self._new._send_command(f"WR DM25 {pos_num}") + await self._new._send_command(f"WR DM5 {n}") + await self._new._send_command("ST 1904") + await self._new._wait_ready() + + plate.barcode = await self._new.read_barcode_inline(m, n, self._barcode_adapter) + + await self._new._send_command("ST 1903") + + async def move_position_to_position( + self, plate: Plate, dest_site: PlateHolder, read_barcode: bool = False + ): + if not read_barcode: + await self._new.move_position_to_position(plate, dest_site) + return + + # Can't use the bundled move method because the original reads barcode + # AFTER DM writes but BEFORE ST 1908 (pick plate). + if self.barcode_scanner is None: + raise RuntimeError("Barcode scanner not configured for this incubator instance") + orig_site = plate.parent + assert isinstance(orig_site, PlateHolder) + assert isinstance(dest_site, PlateHolder) + + if dest_site.resource is not None: + raise RuntimeError(f"Position {dest_site} already has a plate assigned!") + + orig_m, orig_n = self._new._site_to_m_n(orig_site) + dest_m, dest_n = self._new._site_to_m_n(dest_site) + orig_step_size, orig_pos_num = self._new._carrier_to_steps_pos(orig_site) + dest_step_size, dest_pos_num = self._new._carrier_to_steps_pos(dest_site) + + await self._new._send_command(f"WR DM0 {orig_m}") + await self._new._send_command(f"WR DM23 {orig_step_size}") + await self._new._send_command(f"WR DM25 {orig_pos_num}") + await self._new._send_command(f"WR DM5 {orig_n}") + + plate.barcode = await self._new.read_barcode_inline(orig_m, orig_n, self._barcode_adapter) + + await self._new._send_command("ST 1908") + await self._new._wait_ready() + + if orig_m != dest_m: + await self._new._send_command(f"WR DM0 {dest_m}") + await self._new._send_command(f"WR DM23 {dest_step_size}") + await self._new._send_command(f"WR DM25 {dest_pos_num}") + await self._new._send_command(f"WR DM5 {dest_n}") + await self._new._send_command("ST 1909") + await self._new._wait_ready() + await self._new._send_command("ST 1903") + + async def set_temperature(self, temperature: float): + await self._new.set_temperature(temperature) + + async def get_temperature(self) -> float: + return await self._new.get_current_temperature() + + async def start_shaking(self, frequency): + await self._new.start_shaking(speed=frequency) + + async def stop_shaking(self): + await self._new.stop_shaking() + + async def get_shaker_speed(self) -> float: + return await self._new.get_shaker_speed() + + async def shaker_status(self) -> int: + raise NotImplementedError("shaker_status command not yet implemented") + + async def get_target_temperature(self) -> float: + return await self._new.get_target_temperature() + + async def set_humidity(self, humidity: float): + await self._new.set_humidity(humidity) + + async def get_humidity(self) -> float: + return await self._new.get_current_humidity() + + async def get_target_humidity(self) -> float: + return await self._new.get_target_humidity() + + async def set_co2_level(self, co2_level: float): + await self._new.set_co2_level(co2_level) + + async def get_co2_level(self) -> float: + return await self._new.get_co2_level() + + async def get_target_co2_level(self) -> float: + return await self._new.get_target_co2_level() + + async def set_n2_level(self, n2_level: float): + await self._new.set_n2_level(n2_level) + + async def get_n2_level(self) -> float: + return await self._new.get_n2_level() + + async def get_target_n2_level(self) -> float: + return await self._new.get_target_n2_level() + + async def turn_swap_station(self, home: bool): + await self._new.turn_swap_station(home) + + async def check_shovel_sensor(self) -> bool: + return await self._new.check_shovel_sensor() + + async def check_transfer_sensor(self) -> bool: + return await self._new.check_transfer_sensor() + + async def check_second_transfer_sensor(self) -> bool: + return await self._new.check_second_transfer_sensor() + + async def read_barcode_inline(self, cassette: int, plt_position: int): + if self.barcode_scanner is None: + raise RuntimeError("Barcode scanner not configured for this incubator instance") + return await self._new.read_barcode_inline(cassette, plt_position, self._barcode_adapter) + + async def scan_barcode(self, site: PlateHolder): + if self.barcode_scanner is None: + raise RuntimeError("Barcode scanner not configured for this incubator instance") + return await self._new.scan_barcode(site, self._barcode_adapter) + + def serialize(self) -> dict: + return self._new.serialize() + + @classmethod + def deserialize(cls, data: dict): + return cls(port=data["port"], model=data["model"]) diff --git a/pylabrobot/storage/liconic/liconic_backend_tests.py b/pylabrobot/legacy/storage/liconic/liconic_backend_tests.py similarity index 98% rename from pylabrobot/storage/liconic/liconic_backend_tests.py rename to pylabrobot/legacy/storage/liconic/liconic_backend_tests.py index b3ed09dccfa..6c72ab22816 100644 --- a/pylabrobot/storage/liconic/liconic_backend_tests.py +++ b/pylabrobot/legacy/storage/liconic/liconic_backend_tests.py @@ -7,18 +7,18 @@ pytest.importorskip("serial") -from pylabrobot.resources import PlateHolder -from pylabrobot.resources.carrier import PlateCarrier -from pylabrobot.storage.liconic.constants import LiconicType -from pylabrobot.storage.liconic.liconic_backend import ( +from pylabrobot.legacy.storage.liconic.constants import LiconicType +from pylabrobot.legacy.storage.liconic.liconic_backend import ( LICONIC_SITE_HEIGHT_TO_STEPS, ExperimentalLiconicBackend, ) -from pylabrobot.storage.liconic.racks import ( +from pylabrobot.legacy.storage.liconic.racks import ( liconic_rack_5mm_42, liconic_rack_17mm_22, liconic_rack_44mm_10, ) +from pylabrobot.resources import PlateHolder +from pylabrobot.resources.carrier import PlateCarrier class TestStepSizeFormula(unittest.TestCase): @@ -398,7 +398,7 @@ async def test_send_command_raises_on_empty_response(self): await self.backend._send_command("RD 1915") async def test_send_command_raises_on_controller_error(self): - from pylabrobot.storage.liconic.errors import LiconicControllerCommandError + from pylabrobot.legacy.storage.liconic.errors import LiconicControllerCommandError self.backend.io.read = AsyncMock(return_value=b"E1") with self.assertRaises(LiconicControllerCommandError): diff --git a/pylabrobot/legacy/storage/liconic/racks.py b/pylabrobot/legacy/storage/liconic/racks.py new file mode 100644 index 00000000000..50b897d34b6 --- /dev/null +++ b/pylabrobot/legacy/storage/liconic/racks.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.liconic.racks instead.""" + +from pylabrobot.liconic.racks import * # noqa: F401,F403 diff --git a/pylabrobot/temperature_controlling/__init__.py b/pylabrobot/legacy/temperature_controlling/__init__.py similarity index 100% rename from pylabrobot/temperature_controlling/__init__.py rename to pylabrobot/legacy/temperature_controlling/__init__.py diff --git a/pylabrobot/temperature_controlling/backend.py b/pylabrobot/legacy/temperature_controlling/backend.py similarity index 83% rename from pylabrobot/temperature_controlling/backend.py rename to pylabrobot/legacy/temperature_controlling/backend.py index 3fdeff95584..34df99a2bae 100644 --- a/pylabrobot/temperature_controlling/backend.py +++ b/pylabrobot/legacy/temperature_controlling/backend.py @@ -1,6 +1,8 @@ +"""Legacy. Use pylabrobot.capabilities.temperature_controlling instead.""" + from abc import ABCMeta, abstractmethod -from pylabrobot.machines.backend import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class TemperatureControllerBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/temperature_controlling/chatterbox.py b/pylabrobot/legacy/temperature_controlling/chatterbox.py similarity index 93% rename from pylabrobot/temperature_controlling/chatterbox.py rename to pylabrobot/legacy/temperature_controlling/chatterbox.py index b53510080ac..0cdc84dfb2d 100644 --- a/pylabrobot/temperature_controlling/chatterbox.py +++ b/pylabrobot/legacy/temperature_controlling/chatterbox.py @@ -1,4 +1,4 @@ -from pylabrobot.temperature_controlling.backend import ( +from pylabrobot.legacy.temperature_controlling.backend import ( TemperatureControllerBackend, ) diff --git a/pylabrobot/temperature_controlling/inheco/__init__.py b/pylabrobot/legacy/temperature_controlling/inheco/__init__.py similarity index 100% rename from pylabrobot/temperature_controlling/inheco/__init__.py rename to pylabrobot/legacy/temperature_controlling/inheco/__init__.py diff --git a/pylabrobot/legacy/temperature_controlling/inheco/control_box.py b/pylabrobot/legacy/temperature_controlling/inheco/control_box.py new file mode 100644 index 00000000000..306e97d33c2 --- /dev/null +++ b/pylabrobot/legacy/temperature_controlling/inheco/control_box.py @@ -0,0 +1,5 @@ +"""Legacy re-export. Use pylabrobot.inheco.InhecoTECControlBox instead.""" + +from pylabrobot.inheco.control_box import InhecoTECControlBox + +__all__ = ["InhecoTECControlBox"] diff --git a/pylabrobot/temperature_controlling/inheco/cpac.py b/pylabrobot/legacy/temperature_controlling/inheco/cpac.py similarity index 68% rename from pylabrobot/temperature_controlling/inheco/cpac.py rename to pylabrobot/legacy/temperature_controlling/inheco/cpac.py index b566e395a29..32646e520ea 100644 --- a/pylabrobot/temperature_controlling/inheco/cpac.py +++ b/pylabrobot/legacy/temperature_controlling/inheco/cpac.py @@ -1,7 +1,7 @@ +from pylabrobot.legacy.temperature_controlling.inheco.control_box import InhecoTECControlBox +from pylabrobot.legacy.temperature_controlling.inheco.cpac_backend import InhecoCPACBackend +from pylabrobot.legacy.temperature_controlling.temperature_controller import TemperatureController from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.temperature_controlling.inheco.control_box import InhecoTECControlBox -from pylabrobot.temperature_controlling.inheco.cpac_backend import InhecoCPACBackend -from pylabrobot.temperature_controlling.temperature_controller import TemperatureController def inheco_cpac_ultraflat( diff --git a/pylabrobot/legacy/temperature_controlling/inheco/cpac_backend.py b/pylabrobot/legacy/temperature_controlling/inheco/cpac_backend.py new file mode 100644 index 00000000000..b5932d3b9f8 --- /dev/null +++ b/pylabrobot/legacy/temperature_controlling/inheco/cpac_backend.py @@ -0,0 +1,11 @@ +from pylabrobot.inheco import cpac +from pylabrobot.legacy.temperature_controlling.inheco.temperature_controller import ( + InhecoTemperatureControllerBackend, +) + + +class InhecoCPACBackend(InhecoTemperatureControllerBackend): + """Legacy. Use pylabrobot.inheco.cpac.InhecoCPACBackend instead.""" + + def __init__(self, index: int, control_box): + self._new = cpac.InhecoCPACBackend(index=index, control_box=control_box) diff --git a/pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py b/pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py new file mode 100644 index 00000000000..fd9ab2160eb --- /dev/null +++ b/pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py @@ -0,0 +1,51 @@ +from pylabrobot.inheco import cpac +from pylabrobot.legacy.temperature_controlling.backend import TemperatureControllerBackend + + +class InhecoTemperatureControllerBackend(TemperatureControllerBackend): + """Legacy. Use pylabrobot.inheco.cpac.InhecoTemperatureControllerBackend instead.""" + + def __init__(self, index: int, control_box): + self._new = cpac.InhecoTemperatureControllerBackend(index=index, control_box=control_box) + + @property + def index(self) -> int: + return self._new.index + + @property + def interface(self): + return self._new.interface + + @property + def supports_active_cooling(self) -> bool: + return self._new.supports_active_cooling + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() + + def serialize(self) -> dict: + return self._new.serialize() + + async def set_temperature(self, temperature: float): + await self._new.set_temperature(temperature) + + async def get_current_temperature(self) -> float: + return await self._new.get_current_temperature() + + async def deactivate(self): + await self._new.deactivate() + + async def set_target_temperature(self, temperature: float): + await self._new.set_target_temperature(temperature) + + async def start_temperature_control(self): + return await self._new.start_temperature_control() + + async def stop_temperature_control(self): + return await self._new.stop_temperature_control() + + async def get_device_info(self, info_type: int): + return await self._new.get_device_info(info_type) diff --git a/pylabrobot/temperature_controlling/opentrons.py b/pylabrobot/legacy/temperature_controlling/opentrons.py similarity index 87% rename from pylabrobot/temperature_controlling/opentrons.py rename to pylabrobot/legacy/temperature_controlling/opentrons.py index 771789c3aa3..8bfd0c42893 100644 --- a/pylabrobot/temperature_controlling/opentrons.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons.py @@ -1,17 +1,17 @@ from typing import Optional -from pylabrobot.resources import Coordinate, ItemizedResource -from pylabrobot.resources.opentrons.module import OTModule -from pylabrobot.temperature_controlling.backend import TemperatureControllerBackend -from pylabrobot.temperature_controlling.opentrons_backend import ( +from pylabrobot.legacy.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.legacy.temperature_controlling.opentrons_backend import ( OpentronsTemperatureModuleBackend, ) -from pylabrobot.temperature_controlling.opentrons_backend_usb import ( +from pylabrobot.legacy.temperature_controlling.opentrons_backend_usb import ( OpentronsTemperatureModuleUSBBackend, ) -from pylabrobot.temperature_controlling.temperature_controller import ( +from pylabrobot.legacy.temperature_controlling.temperature_controller import ( TemperatureController, ) +from pylabrobot.resources import Coordinate, ItemizedResource +from pylabrobot.resources.opentrons.module import OTModule class OpentronsTemperatureModuleV2(TemperatureController, OTModule): diff --git a/pylabrobot/temperature_controlling/opentrons_backend.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py similarity index 96% rename from pylabrobot/temperature_controlling/opentrons_backend.py rename to pylabrobot/legacy/temperature_controlling/opentrons_backend.py index 4072ea4ae0e..c1d4571a28a 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py @@ -1,6 +1,6 @@ from typing import cast -from pylabrobot.temperature_controlling.backend import ( +from pylabrobot.legacy.temperature_controlling.backend import ( TemperatureControllerBackend, ) diff --git a/pylabrobot/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py similarity index 97% rename from pylabrobot/temperature_controlling/opentrons_backend_usb.py rename to pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py index dd941633d55..2616020cf1a 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py @@ -1,7 +1,7 @@ from typing import Optional from pylabrobot.io.serial import Serial -from pylabrobot.temperature_controlling.backend import ( +from pylabrobot.legacy.temperature_controlling.backend import ( TemperatureControllerBackend, ) diff --git a/pylabrobot/legacy/temperature_controlling/temperature_controller.py b/pylabrobot/legacy/temperature_controlling/temperature_controller.py new file mode 100644 index 00000000000..9fa9851bf6e --- /dev/null +++ b/pylabrobot/legacy/temperature_controlling/temperature_controller.py @@ -0,0 +1,97 @@ +from typing import Optional + +from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.capabilities.temperature_controlling import ( + TemperatureControllerBackend as _NewTCBackend, +) +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.resources import Coordinate, ResourceHolder + +from .backend import TemperatureControllerBackend + + +class _TemperatureControlAdapter(_NewTCBackend): + def __init__(self, legacy: TemperatureControllerBackend): + self._legacy = legacy + + async def setup(self): + pass + + async def stop(self): + pass + + @property + def supports_active_cooling(self) -> bool: + return self._legacy.supports_active_cooling + + async def set_temperature(self, temperature: float): + await self._legacy.set_temperature(temperature) + + async def get_current_temperature(self) -> float: + return await self._legacy.get_current_temperature() + + async def deactivate(self): + await self._legacy.deactivate() + + +class TemperatureController(ResourceHolder, Machine): + """Legacy. Use pylabrobot.inheco.InhecoCPAC (or vendor-specific class) instead.""" + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: TemperatureControllerBackend, + child_location: Coordinate, + category: str = "temperature_controller", + model: Optional[str] = None, + ): + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + child_location=child_location, + category=category, + model=model, + ) + Machine.__init__(self, backend=backend) + self.backend: TemperatureControllerBackend = backend + self._tc_cap = TemperatureControlCapability(backend=_TemperatureControlAdapter(backend)) + + @property + def target_temperature(self) -> Optional[float]: + return self._tc_cap.target_temperature + + @target_temperature.setter + def target_temperature(self, value: Optional[float]): + self._tc_cap.target_temperature = value + + async def setup(self, **backend_kwargs): + await super().setup(**backend_kwargs) + await self._tc_cap._on_setup() + + async def set_temperature(self, temperature: float, passive: bool = False): + return await self._tc_cap.set_temperature(temperature, passive=passive) + + async def get_temperature(self) -> float: + return await self._tc_cap.get_temperature() + + async def wait_for_temperature(self, timeout: float = 300.0, tolerance: float = 0.5) -> None: + return await self._tc_cap.wait_for_temperature(timeout=timeout, tolerance=tolerance) + + async def deactivate(self): + return await self._tc_cap.deactivate() + + async def stop(self): + await self._tc_cap._on_stop() + await super().stop() + + def serialize(self) -> dict: + return { + **Machine.serialize(self), + **ResourceHolder.serialize(self), + } diff --git a/pylabrobot/temperature_controlling/temperature_controller_tests.py b/pylabrobot/legacy/temperature_controlling/temperature_controller_tests.py similarity index 94% rename from pylabrobot/temperature_controlling/temperature_controller_tests.py rename to pylabrobot/legacy/temperature_controlling/temperature_controller_tests.py index 5e2a5c8cb86..14f8b9a1787 100644 --- a/pylabrobot/temperature_controlling/temperature_controller_tests.py +++ b/pylabrobot/legacy/temperature_controlling/temperature_controller_tests.py @@ -1,11 +1,11 @@ import unittest -from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.temperature_controlling import ( +from pylabrobot.legacy.temperature_controlling import ( TemperatureController, TemperatureControllerChatterboxBackend, ) -from pylabrobot.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.legacy.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.resources.coordinate import Coordinate class TemperatureControllerTests(unittest.TestCase): diff --git a/pylabrobot/thermocycling/__init__.py b/pylabrobot/legacy/thermocycling/__init__.py similarity index 100% rename from pylabrobot/thermocycling/__init__.py rename to pylabrobot/legacy/thermocycling/__init__.py diff --git a/pylabrobot/thermocycling/backend.py b/pylabrobot/legacy/thermocycling/backend.py similarity index 94% rename from pylabrobot/thermocycling/backend.py rename to pylabrobot/legacy/thermocycling/backend.py index cf0955364ae..d0d089699ef 100644 --- a/pylabrobot/thermocycling/backend.py +++ b/pylabrobot/legacy/thermocycling/backend.py @@ -1,8 +1,8 @@ from abc import ABCMeta, abstractmethod from typing import List -from pylabrobot.machines.backend import MachineBackend -from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol +from pylabrobot.legacy.machines.backend import MachineBackend +from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol class ThermocyclerBackend(MachineBackend, metaclass=ABCMeta): diff --git a/pylabrobot/thermocycling/chatterbox.py b/pylabrobot/legacy/thermocycling/chatterbox.py similarity index 97% rename from pylabrobot/thermocycling/chatterbox.py rename to pylabrobot/legacy/thermocycling/chatterbox.py index 1c45e40752d..46dd31bc62a 100644 --- a/pylabrobot/thermocycling/chatterbox.py +++ b/pylabrobot/legacy/thermocycling/chatterbox.py @@ -1,8 +1,8 @@ from dataclasses import dataclass from typing import List, Optional -from pylabrobot.thermocycling.backend import ThermocyclerBackend -from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol +from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol @dataclass diff --git a/pylabrobot/thermocycling/chatterbox_tests.py b/pylabrobot/legacy/thermocycling/chatterbox_tests.py similarity index 94% rename from pylabrobot/thermocycling/chatterbox_tests.py rename to pylabrobot/legacy/thermocycling/chatterbox_tests.py index dcd85299aae..b483f481128 100644 --- a/pylabrobot/thermocycling/chatterbox_tests.py +++ b/pylabrobot/legacy/thermocycling/chatterbox_tests.py @@ -2,9 +2,9 @@ from contextlib import redirect_stdout from io import StringIO +from pylabrobot.legacy.thermocycling import Thermocycler, ThermocyclerChatterboxBackend +from pylabrobot.legacy.thermocycling.standard import Protocol, Stage, Step from pylabrobot.resources import Coordinate -from pylabrobot.thermocycling import Thermocycler, ThermocyclerChatterboxBackend -from pylabrobot.thermocycling.standard import Protocol, Stage, Step class TestThermocyclerChatterbox(unittest.IsolatedAsyncioTestCase): diff --git a/pylabrobot/thermocycling/inheco/__init__.py b/pylabrobot/legacy/thermocycling/inheco/__init__.py similarity index 100% rename from pylabrobot/thermocycling/inheco/__init__.py rename to pylabrobot/legacy/thermocycling/inheco/__init__.py diff --git a/pylabrobot/thermocycling/inheco/odtc_backend.py b/pylabrobot/legacy/thermocycling/inheco/odtc_backend.py similarity index 98% rename from pylabrobot/thermocycling/inheco/odtc_backend.py rename to pylabrobot/legacy/thermocycling/inheco/odtc_backend.py index 84e1b69665b..327a60955e3 100644 --- a/pylabrobot/thermocycling/inheco/odtc_backend.py +++ b/pylabrobot/legacy/thermocycling/inheco/odtc_backend.py @@ -4,9 +4,12 @@ import xml.etree.ElementTree as ET from typing import Any, Dict, List, Optional -from pylabrobot.storage.inheco.scila.inheco_sila_interface import InhecoSiLAInterface, SiLAError -from pylabrobot.thermocycling.backend import ThermocyclerBackend -from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol +from pylabrobot.legacy.storage.inheco.scila.inheco_sila_interface import ( + InhecoSiLAInterface, + SiLAError, +) +from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol def _format_number(n: Any) -> str: diff --git a/pylabrobot/thermocycling/opentrons.py b/pylabrobot/legacy/thermocycling/opentrons.py similarity index 95% rename from pylabrobot/thermocycling/opentrons.py rename to pylabrobot/legacy/thermocycling/opentrons.py index 6c8d8546b3c..bc2b9738f3b 100644 --- a/pylabrobot/thermocycling/opentrons.py +++ b/pylabrobot/legacy/thermocycling/opentrons.py @@ -2,10 +2,10 @@ from typing import Optional, cast +from pylabrobot.legacy.thermocycling.opentrons_backend import OpentronsThermocyclerBackend +from pylabrobot.legacy.thermocycling.thermocycler import Thermocycler from pylabrobot.resources import Coordinate, ItemizedResource from pylabrobot.resources.opentrons.module import OTModule -from pylabrobot.thermocycling.opentrons_backend import OpentronsThermocyclerBackend -from pylabrobot.thermocycling.thermocycler import Thermocycler class OpentronsThermocyclerModuleV1(Thermocycler, OTModule): diff --git a/pylabrobot/thermocycling/opentrons_backend.py b/pylabrobot/legacy/thermocycling/opentrons_backend.py similarity index 97% rename from pylabrobot/thermocycling/opentrons_backend.py rename to pylabrobot/legacy/thermocycling/opentrons_backend.py index f2b444c786d..9cd569bd2c0 100644 --- a/pylabrobot/thermocycling/opentrons_backend.py +++ b/pylabrobot/legacy/thermocycling/opentrons_backend.py @@ -2,8 +2,8 @@ from typing import List, Optional, cast -from pylabrobot.thermocycling.backend import ThermocyclerBackend -from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol +from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol try: from ot_api.modules import ( diff --git a/pylabrobot/thermocycling/opentrons_backend_tests.py b/pylabrobot/legacy/thermocycling/opentrons_backend_tests.py similarity index 80% rename from pylabrobot/thermocycling/opentrons_backend_tests.py rename to pylabrobot/legacy/thermocycling/opentrons_backend_tests.py index b883b99b1f4..59f7600a82a 100644 --- a/pylabrobot/thermocycling/opentrons_backend_tests.py +++ b/pylabrobot/legacy/thermocycling/opentrons_backend_tests.py @@ -5,10 +5,10 @@ pytest.importorskip("ot_api") +from pylabrobot.legacy.thermocycling.opentrons import OpentronsThermocyclerModuleV1 +from pylabrobot.legacy.thermocycling.opentrons_backend import OpentronsThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol, Stage, Step from pylabrobot.resources.itemized_resource import ItemizedResource -from pylabrobot.thermocycling.opentrons import OpentronsThermocyclerModuleV1 -from pylabrobot.thermocycling.opentrons_backend import OpentronsThermocyclerBackend -from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol, Stage, Step class TestOpentronsThermocyclerBackend(unittest.IsolatedAsyncioTestCase): @@ -29,7 +29,7 @@ def test_opentrons_v1_serialization(self): deserialized = OpentronsThermocyclerModuleV1.deserialize(serialized) assert tc_model == deserialized - @patch("pylabrobot.thermocycling.opentrons_backend.list_connected_modules") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.list_connected_modules") async def test_find_module_raises_error_if_not_found(self, mock_list_connected_modules): """Test that an error is raised if the module is not found.""" mock_list_connected_modules.return_value = [{"id": "some_other_id", "data": {}}] @@ -37,37 +37,37 @@ async def test_find_module_raises_error_if_not_found(self, mock_list_connected_m await self.thermocycler_backend.get_lid_open() self.assertEqual(str(e.exception), "Module 'test_id' not found") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_open_lid") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_open_lid") async def test_open_lid(self, mock_open_lid): await self.thermocycler_backend.open_lid() mock_open_lid.assert_called_once_with(module_id="test_id") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_close_lid") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_close_lid") async def test_close_lid(self, mock_close_lid): await self.thermocycler_backend.close_lid() mock_close_lid.assert_called_once_with(module_id="test_id") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_set_block_temperature") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_set_block_temperature") async def test_set_block_temperature(self, mock_set_block_temp): await self.thermocycler_backend.set_block_temperature([95.0]) mock_set_block_temp.assert_called_once_with(celsius=95.0, module_id="test_id") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_set_lid_temperature") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_set_lid_temperature") async def test_set_lid_temperature(self, mock_set_lid_temp): await self.thermocycler_backend.set_lid_temperature([105.0]) mock_set_lid_temp.assert_called_once_with(celsius=105.0, module_id="test_id") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_deactivate_block") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_deactivate_block") async def test_deactivate_block(self, mock_deactivate_block): await self.thermocycler_backend.deactivate_block() mock_deactivate_block.assert_called_once_with(module_id="test_id") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_deactivate_lid") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_deactivate_lid") async def test_deactivate_lid(self, mock_deactivate_lid): await self.thermocycler_backend.deactivate_lid() mock_deactivate_lid.assert_called_once_with(module_id="test_id") - @patch("pylabrobot.thermocycling.opentrons_backend.thermocycler_run_profile_no_wait") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.thermocycler_run_profile_no_wait") async def test_run_protocol(self, mock_run_profile): protocol = Protocol(stages=[Stage(steps=[Step(temperature=[95], hold_seconds=10)], repeats=1)]) await self.thermocycler_backend.run_protocol(protocol, 50.0) @@ -76,7 +76,7 @@ async def test_run_protocol(self, mock_run_profile): profile=[{"celsius": 95, "holdSeconds": 10}], block_max_volume=50.0, module_id="test_id" ) - @patch("pylabrobot.thermocycling.opentrons_backend.list_connected_modules") + @patch("pylabrobot.legacy.thermocycling.opentrons_backend.list_connected_modules") async def test_getters_return_correct_data(self, mock_list_connected_modules): mock_data = { "id": "test_id", diff --git a/pylabrobot/thermocycling/opentrons_backend_usb.py b/pylabrobot/legacy/thermocycling/opentrons_backend_usb.py similarity index 98% rename from pylabrobot/thermocycling/opentrons_backend_usb.py rename to pylabrobot/legacy/thermocycling/opentrons_backend_usb.py index 41daf9a002c..3c5f809205d 100644 --- a/pylabrobot/thermocycling/opentrons_backend_usb.py +++ b/pylabrobot/legacy/thermocycling/opentrons_backend_usb.py @@ -4,8 +4,8 @@ import asyncio from typing import List, Optional -from pylabrobot.thermocycling.backend import ThermocyclerBackend -from pylabrobot.thermocycling.standard import ( +from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import ( BlockStatus, LidStatus, Protocol, diff --git a/pylabrobot/thermocycling/standard.py b/pylabrobot/legacy/thermocycling/standard.py similarity index 100% rename from pylabrobot/thermocycling/standard.py rename to pylabrobot/legacy/thermocycling/standard.py diff --git a/pylabrobot/thermocycling/thermo_fisher/__init__.py b/pylabrobot/legacy/thermocycling/thermo_fisher/__init__.py similarity index 100% rename from pylabrobot/thermocycling/thermo_fisher/__init__.py rename to pylabrobot/legacy/thermocycling/thermo_fisher/__init__.py diff --git a/pylabrobot/thermocycling/thermo_fisher/atc.py b/pylabrobot/legacy/thermocycling/thermo_fisher/atc.py similarity index 89% rename from pylabrobot/thermocycling/thermo_fisher/atc.py rename to pylabrobot/legacy/thermocycling/thermo_fisher/atc.py index c2f070994b0..ae7b701ab01 100644 --- a/pylabrobot/thermocycling/thermo_fisher/atc.py +++ b/pylabrobot/legacy/thermocycling/thermo_fisher/atc.py @@ -1,4 +1,4 @@ -from pylabrobot.thermocycling.thermo_fisher.thermo_fisher_thermocycler import ( +from pylabrobot.legacy.thermocycling.thermo_fisher.thermo_fisher_thermocycler import ( ThermoFisherThermocyclerBackend, ) diff --git a/pylabrobot/thermocycling/thermo_fisher/proflex.py b/pylabrobot/legacy/thermocycling/thermo_fisher/proflex.py similarity index 79% rename from pylabrobot/thermocycling/thermo_fisher/proflex.py rename to pylabrobot/legacy/thermocycling/thermo_fisher/proflex.py index da6c387e239..8105c735f5f 100644 --- a/pylabrobot/thermocycling/thermo_fisher/proflex.py +++ b/pylabrobot/legacy/thermocycling/thermo_fisher/proflex.py @@ -1,4 +1,4 @@ -from pylabrobot.thermocycling.thermo_fisher.thermo_fisher_thermocycler import ( +from pylabrobot.legacy.thermocycling.thermo_fisher.thermo_fisher_thermocycler import ( ThermoFisherThermocyclerBackend, ) diff --git a/pylabrobot/thermocycling/thermo_fisher/proflex_tests.py b/pylabrobot/legacy/thermocycling/thermo_fisher/proflex_tests.py similarity index 98% rename from pylabrobot/thermocycling/thermo_fisher/proflex_tests.py rename to pylabrobot/legacy/thermocycling/thermo_fisher/proflex_tests.py index 0f37d0ba393..2d4854751b2 100644 --- a/pylabrobot/thermocycling/thermo_fisher/proflex_tests.py +++ b/pylabrobot/legacy/thermocycling/thermo_fisher/proflex_tests.py @@ -2,8 +2,8 @@ import unittest import unittest.mock -from pylabrobot.thermocycling.standard import Protocol, Stage, Step -from pylabrobot.thermocycling.thermo_fisher.proflex import ProflexBackend +from pylabrobot.legacy.thermocycling.standard import Protocol, Stage, Step +from pylabrobot.legacy.thermocycling.thermo_fisher.proflex import ProflexBackend class TestProflexBackend(unittest.IsolatedAsyncioTestCase): diff --git a/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py b/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py similarity index 99% rename from pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py rename to pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py index 86bab4c0e3d..79aac61d343 100644 --- a/pylabrobot/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py +++ b/pylabrobot/legacy/thermocycling/thermo_fisher/thermo_fisher_thermocycler.py @@ -13,8 +13,8 @@ from xml.dom import minidom from pylabrobot.io import Socket -from pylabrobot.thermocycling.backend import ThermocyclerBackend -from pylabrobot.thermocycling.standard import LidStatus, Protocol, Stage, Step +from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import LidStatus, Protocol, Stage, Step def _generate_run_info_files( diff --git a/pylabrobot/thermocycling/thermocycler.py b/pylabrobot/legacy/thermocycling/thermocycler.py similarity index 98% rename from pylabrobot/thermocycling/thermocycler.py rename to pylabrobot/legacy/thermocycling/thermocycler.py index 622599d47a2..96e107315bc 100644 --- a/pylabrobot/thermocycling/thermocycler.py +++ b/pylabrobot/legacy/thermocycling/thermocycler.py @@ -4,10 +4,10 @@ import time from typing import List, Optional -from pylabrobot.machines.machine import Machine +from pylabrobot.legacy.machines.machine import Machine +from pylabrobot.legacy.thermocycling.backend import ThermocyclerBackend +from pylabrobot.legacy.thermocycling.standard import BlockStatus, LidStatus, Protocol, Stage, Step from pylabrobot.resources import Coordinate, ResourceHolder -from pylabrobot.thermocycling.backend import ThermocyclerBackend -from pylabrobot.thermocycling.standard import BlockStatus, LidStatus, Protocol, Stage, Step class Thermocycler(ResourceHolder, Machine): diff --git a/pylabrobot/thermocycling/thermocycler_tests.py b/pylabrobot/legacy/thermocycling/thermocycler_tests.py similarity index 96% rename from pylabrobot/thermocycling/thermocycler_tests.py rename to pylabrobot/legacy/thermocycling/thermocycler_tests.py index 157d6762358..b63ba8e6f47 100644 --- a/pylabrobot/thermocycling/thermocycler_tests.py +++ b/pylabrobot/legacy/thermocycling/thermocycler_tests.py @@ -2,13 +2,13 @@ import unittest from unittest.mock import AsyncMock, MagicMock -from pylabrobot.resources import Coordinate -from pylabrobot.thermocycling import ( +from pylabrobot.legacy.thermocycling import ( Thermocycler, ThermocyclerBackend, ThermocyclerChatterboxBackend, ) -from pylabrobot.thermocycling.standard import Protocol, Stage, Step +from pylabrobot.legacy.thermocycling.standard import Protocol, Stage, Step +from pylabrobot.resources import Coordinate def mock_backend() -> MagicMock: @@ -52,7 +52,7 @@ def __init__(self, *args, **kwargs): def test_thermocycler_serialization(self): """Test that the high-level resource serializes and deserializes correctly.""" - self.tc.backend = ThermocyclerChatterboxBackend() + self.tc._backend = ThermocyclerChatterboxBackend() serialized = self.tc.serialize() deserialized = Thermocycler.deserialize(serialized) assert self.tc == deserialized diff --git a/pylabrobot/tilting/__init__.py b/pylabrobot/legacy/tilting/__init__.py similarity index 63% rename from pylabrobot/tilting/__init__.py rename to pylabrobot/legacy/tilting/__init__.py index 44f89afcdc8..9f7be386bca 100644 --- a/pylabrobot/tilting/__init__.py +++ b/pylabrobot/legacy/tilting/__init__.py @@ -1,3 +1,5 @@ +"""Legacy. Use pylabrobot.capabilities.tilting and pylabrobot.hamilton.tilt_module instead.""" + from .hamilton import HamiltonTiltModule from .hamilton_backend import HamiltonTiltModuleBackend from .tilter import Tilter diff --git a/pylabrobot/legacy/tilting/chatterbox.py b/pylabrobot/legacy/tilting/chatterbox.py new file mode 100644 index 00000000000..9c8d48493a0 --- /dev/null +++ b/pylabrobot/legacy/tilting/chatterbox.py @@ -0,0 +1,14 @@ +"""Legacy. Use pylabrobot.capabilities.tilting instead.""" + +from pylabrobot.capabilities.tilting import TilterBackend + + +class TilterChatterboxBackend(TilterBackend): + async def setup(self): + pass + + async def stop(self): + pass + + async def set_angle(self, angle: float): + pass diff --git a/pylabrobot/legacy/tilting/hamilton.py b/pylabrobot/legacy/tilting/hamilton.py new file mode 100644 index 00000000000..614d2c6444d --- /dev/null +++ b/pylabrobot/legacy/tilting/hamilton.py @@ -0,0 +1,3 @@ +"""Legacy. Use pylabrobot.hamilton.tilt_module.HamiltonTiltModule instead.""" + +from pylabrobot.hamilton.tilt_module import HamiltonTiltModule # noqa: F401 diff --git a/pylabrobot/legacy/tilting/hamilton_backend.py b/pylabrobot/legacy/tilting/hamilton_backend.py new file mode 100644 index 00000000000..d046ec8823b --- /dev/null +++ b/pylabrobot/legacy/tilting/hamilton_backend.py @@ -0,0 +1,7 @@ +"""Legacy. Use pylabrobot.hamilton.tilt_module instead.""" + +from pylabrobot.capabilities.tilting.backend import TiltModuleError # noqa: F401 +from pylabrobot.hamilton.tilt_module.backend import ( # noqa: F401 + HamiltonTiltModuleBackend, + HamiltonTiltModuleChatterboxBackend, +) diff --git a/pylabrobot/tilting/tilter.py b/pylabrobot/legacy/tilting/tilter.py similarity index 52% rename from pylabrobot/tilting/tilter.py rename to pylabrobot/legacy/tilting/tilter.py index c833181186e..2eadf6c7fb9 100644 --- a/pylabrobot/tilting/tilter.py +++ b/pylabrobot/legacy/tilting/tilter.py @@ -1,16 +1,33 @@ +"""Legacy. Use a vendor-specific machine class (e.g. HamiltonTiltModule) instead.""" + import math from typing import List, Optional -from pylabrobot.machines import Machine +from pylabrobot.capabilities.tilting import TilterBackend as _NewTilterBackend +from pylabrobot.capabilities.tilting import TiltingCapability +from pylabrobot.legacy.machines import Machine +from pylabrobot.legacy.tilting.tilter_backend import TilterBackend from pylabrobot.resources import Coordinate, Plate from pylabrobot.resources.resource_holder import ResourceHolder from pylabrobot.resources.well import CrossSectionType, Well -from .tilter_backend import TilterBackend + +class _TiltingAdapter(_NewTilterBackend): + def __init__(self, legacy: TilterBackend): + self._legacy = legacy + + async def setup(self): + pass + + async def stop(self): + pass + + async def set_angle(self, angle: float): + await self._legacy.set_angle(angle) class Tilter(ResourceHolder, Machine): - """Resources that tilt plates.""" + """Legacy tilt module machine. In new code, use the vendor-specific machine class.""" def __init__( self, @@ -35,86 +52,57 @@ def __init__( child_location=child_location, ) Machine.__init__(self, backend=backend) - self.backend: TilterBackend = backend # fix type - self._absolute_angle: float = 0 + self.backend: TilterBackend = backend self._hinge_coordinate = hinge_coordinate + self.tilting = TiltingCapability(backend=_TiltingAdapter(backend)) + self._capabilities = [self.tilting] + @property def absolute_angle(self) -> float: - return self._absolute_angle + return self.tilting.absolute_angle @property def hinge_coordinate(self) -> Coordinate: return self._hinge_coordinate async def set_angle(self, absolute_angle: float): - """Set the tilt module to rotate to a given angle. - - Args: - absolute_angle: The absolute (unsigned) angle to set rotation to, in degrees, measured from - horizontal as zero. - """ + await self.tilting.set_angle(absolute_angle) - await self.backend.set_angle(angle=absolute_angle) - self._absolute_angle = absolute_angle + async def tilt(self, relative_angle: float): + await self.tilting.tilt(relative_angle) def experimental_rotate_coordinate_around_hinge( self, absolute_coordinate: Coordinate, angle: float ) -> Coordinate: - """Rotate an absolute coordinate around the hinge of the tilter by a given angle. - - Args: - absolute_coordinate: The coordinate to rotate. - angle: The angle to rotate by, in degrees. Negative is clockwise. - - Returns: - Coordinate: The new coordinate after rotation. - """ theta = math.radians(angle) + origin = self.get_absolute_location("l", "f", "b") - rotation_arm_x = absolute_coordinate.x - ( - self._hinge_coordinate.x + self.get_absolute_location("l", "f", "b").x - ) - rotation_arm_z = absolute_coordinate.z - ( - self._hinge_coordinate.z + self.get_absolute_location("l", "f", "b").z - ) + rotation_arm_x = absolute_coordinate.x - (self._hinge_coordinate.x + origin.x) + rotation_arm_z = absolute_coordinate.z - (self._hinge_coordinate.z + origin.z) x_prime = rotation_arm_x * math.cos(theta) - rotation_arm_z * math.sin(theta) z_prime = rotation_arm_x * math.sin(theta) + rotation_arm_z * math.cos(theta) - new_x = x_prime + (self._hinge_coordinate.x + self.get_absolute_location("l", "f", "b").x) - new_z = z_prime + (self._hinge_coordinate.z + self.get_absolute_location("l", "f", "b").z) + new_x = x_prime + (self._hinge_coordinate.x + origin.x) + new_z = z_prime + (self._hinge_coordinate.z + origin.z) return Coordinate(new_x, absolute_coordinate.y, new_z) def experimental_get_plate_drain_offsets( self, plate: Plate, absolute_angle: Optional[float] = None ) -> List[Coordinate]: - """Get the drain edge offsets for all wells in the given plate, tilted around the hinge at a - given absolute angle. - - Args: - plate: The plate to calculate the offsets for. - absolute_angle: The absolute angle to rotate the plate. If `None`, the current tilt angle. - """ - if absolute_angle is None: - absolute_angle = self._absolute_angle - assert absolute_angle is not None # mypy + absolute_angle = self.tilting.absolute_angle angle = absolute_angle if self._hinge_coordinate.x < self._size_x / 2 else -absolute_angle - - _hinge_side = "l" if self._hinge_coordinate.x < self._size_x / 2 else "r" + hinge_side = "l" if self._hinge_coordinate.x < self._size_x / 2 else "r" well_drain_offsets = [] for well in plate.children: - level_absolute_well_drain_coordinate = well.get_absolute_location(_hinge_side, "c", "b") - rotated_absolute_well_drain_coordinate = self.experimental_rotate_coordinate_around_hinge( - level_absolute_well_drain_coordinate, angle - ) - well_drain_offset = rotated_absolute_well_drain_coordinate - well.get_absolute_location( - "c", "c", "b" - ) - well_drain_offsets.append(well_drain_offset) + level_coord = well.get_absolute_location(hinge_side, "c", "b") + rotated_coord = self.experimental_rotate_coordinate_around_hinge(level_coord, angle) + offset = rotated_coord - well.get_absolute_location("c", "c", "b") + well_drain_offsets.append(offset) return well_drain_offsets @@ -124,21 +112,8 @@ def experimental_get_well_drain_offsets( n_tips: int = 1, absolute_angle: Optional[float] = None, ) -> List[Coordinate]: - """Get the drain edge offsets for the given wells, tilted around the hinge at a - given absolute angle, for multiple tips. - - Args: - wells: The wells to calculate the offsets for. - n_tips: The number of tips to calculate offsets for. Defaults to 1. - absolute_angle: The absolute angle to rotate the wells. If `None`, the current tilt angle. - - Returns: - A list of lists of Coordinates, where each inner list contains the offsets for n_tips. - """ - if absolute_angle is None: - absolute_angle = self._absolute_angle - assert absolute_angle is not None # mypy + absolute_angle = self.tilting.absolute_angle angle = absolute_angle * (-1 if self._hinge_coordinate.x >= self._size_x / 2 else 1) hinge_on_left = self._hinge_coordinate.x < self._size_x / 2 @@ -150,24 +125,20 @@ def experimental_get_well_drain_offsets( "Wells must have circular cross-section" ) - diameter = well.get_absolute_size_x() # assuming circular well + diameter = well.get_absolute_size_x() radius = diameter / 2 if n_tips > 1: assert (n_tips - 1) * min_tip_distance <= diameter, ( f"Cannot fit {n_tips} tips in a well with diameter {diameter} mm" ) - y_offsets = [ ((n_tips - 1) / 2 - tip_index) * min_tip_distance for tip_index in range(n_tips) ] - x_offset = math.sqrt(radius**2 - max(y_offsets) ** 2) x_offset = -x_offset if hinge_on_left else x_offset - tip_coords = [Coordinate(x_offset, y, 0) for y in y_offsets] else: - # Default case: n_tips = 1 x_offset = -radius if hinge_on_left else radius tip_coords = [Coordinate(x_offset, 0, 0)] @@ -183,11 +154,3 @@ def experimental_get_well_drain_offsets( well_drain_offsets.append(offsets) return [offset for well_offsets in well_drain_offsets for offset in well_offsets] - - async def tilt(self, relative_angle: float): - """Tilt the plate contained in the tilt module by a given angle relative to the current angle. - - Args: - relative_angle: The angle to rotate by, in degrees. Clockwise. 0 is horizontal. - """ - await self.set_angle(self._absolute_angle + relative_angle) diff --git a/pylabrobot/tilting/tilter_backend.py b/pylabrobot/legacy/tilting/tilter_backend.py similarity index 68% rename from pylabrobot/tilting/tilter_backend.py rename to pylabrobot/legacy/tilting/tilter_backend.py index 5b2b9653dc3..22996017e3c 100644 --- a/pylabrobot/tilting/tilter_backend.py +++ b/pylabrobot/legacy/tilting/tilter_backend.py @@ -1,6 +1,8 @@ +"""Legacy. Use pylabrobot.capabilities.tilting instead.""" + from abc import ABCMeta, abstractmethod -from pylabrobot.machines.machine import MachineBackend +from pylabrobot.legacy.machines.backend import MachineBackend class TiltModuleError(Exception): @@ -14,9 +16,6 @@ class TilterBackend(MachineBackend, metaclass=ABCMeta): async def set_angle(self, angle: float): """Set the tilt module to rotate by a given angle. - We assume the rotation anchor is the right side of the module. This may change in the future - if we integrate other tilt modules. - Args: angle: The angle to rotate by, in degrees. Clockwise. 0 is horizontal. """ diff --git a/pylabrobot/liconic/__init__.py b/pylabrobot/liconic/__init__.py new file mode 100644 index 00000000000..8fda2ba99f3 --- /dev/null +++ b/pylabrobot/liconic/__init__.py @@ -0,0 +1,3 @@ +from .backend import LiconicBackend +from .constants import LiconicType +from .liconic import Liconic diff --git a/pylabrobot/storage/liconic/liconic_backend.py b/pylabrobot/liconic/backend.py similarity index 54% rename from pylabrobot/storage/liconic/liconic_backend.py rename to pylabrobot/liconic/backend.py index f9b770f727f..ea5bc3baa6c 100644 --- a/pylabrobot/storage/liconic/liconic_backend.py +++ b/pylabrobot/liconic/backend.py @@ -13,14 +13,19 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.barcode_scanners import BarcodeScanner +from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend +from pylabrobot.capabilities.barcode_scanning import BarcodeScannerBackend +from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend +from pylabrobot.capabilities.shaking.backend import ShakerBackend +from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.device import DeviceBackend from pylabrobot.io.serial import Serial from pylabrobot.resources import Plate, PlateHolder from pylabrobot.resources.barcode import Barcode from pylabrobot.resources.carrier import PlateCarrier -from pylabrobot.storage.backend import IncubatorBackend -from pylabrobot.storage.liconic.constants import ControllerError, HandlingError, LiconicType -from pylabrobot.storage.liconic.errors import controller_error_map, handler_error_map + +from .constants import ControllerError, HandlingError, LiconicType +from .errors import controller_error_map, handler_error_map logger = logging.getLogger(__name__) @@ -41,11 +46,13 @@ } -class ExperimentalLiconicBackend(IncubatorBackend): - """Backend for Liconic incubators. - - Optionally accepts a BarcodeScanner instance for internal barcode reading. - """ +class LiconicBackend( + AutomatedRetrievalBackend, + TemperatureControllerBackend, + HumidityControllerBackend, + ShakerBackend, +): + """Backend for Liconic incubators.""" default_baud = 9600 serial_message_encoding = "ascii" @@ -57,7 +64,6 @@ def __init__( self, model: Union[LiconicType, str], port: str, - barcode_scanner: Optional[BarcodeScanner] = None, ): if not HAS_SERIAL: raise RuntimeError( @@ -66,8 +72,6 @@ def __init__( ) super().__init__() - self.barcode_scanner = barcode_scanner - if isinstance(model, str): try: model = LiconicType(model) @@ -92,21 +96,14 @@ def __init__( self.co2_installed: Optional[bool] = None self.n2_installed: Optional[bool] = None - # Function to setup serial connection with Liconic PLC async def setup(self): - """ - 1. Open serial port (9600 8E1, RTS/CTS) via the Serial wrapper. - 2. Send >200 ms break, wait 150 ms, flush buffers. - 3. Handshake: CR → wait for CC - 4. Activate handling: ST 1801 → expect OK - 5. Poll ready-flag: RD 1915 → wait for "1" - """ + await DeviceBackend.setup(self) try: await self.io.setup() except serial.SerialException as e: raise RuntimeError(f"Could not open {self.io.port}: {e}") from e - await self.io.send_break(duration=0.2) # >100 ms required + await self.io.send_break(duration=0.2) await asyncio.sleep(0.15) await self.io.reset_input_buffer() await self.io.reset_output_buffer() @@ -114,7 +111,7 @@ async def setup(self): await self.io.write(b"CR\r") deadline = time.time() + self.init_timeout while time.time() < deadline: - resp = await self.io.readline() # reads through LF + resp = await self.io.readline() if resp.strip() == b"CC": break else: @@ -132,170 +129,161 @@ async def setup(self): await self.io.write(b"RD 1915\r") flag = await self.io.readline() if flag.strip() == b"1": - break + return await asyncio.sleep(self.poll_interval) - else: - await self.io.stop() - raise TimeoutError(f"PLC did not signal ready within {self.start_timeout} seconds") - - def _site_to_m_n(self, site: PlateHolder) -> Tuple[int, int]: - rack = site.parent - assert isinstance(rack, PlateCarrier), "Site not in rack" - assert self._racks is not None, "Racks not set" - rack_idx = self._racks.index(rack) + 1 # plr is 0-indexed, liconic is 1-indexed - site_idx = next(idx for idx, s in rack.sites.items() if s == site) + 1 # 1-indexed - return rack_idx, site_idx - # Wrote this function to return motor step size and plate position number from PlateCarrier model name - def _carrier_to_steps_pos(self, site: PlateHolder) -> Tuple[int, int]: - rack = site.parent - assert isinstance(rack, PlateCarrier), "Site not in rack" - assert self._racks is not None, "Racks not set" - if rack.model is None or not rack.model.startswith("liconic"): - raise ValueError(f"The plate carrier used: {rack.model} is not compatible with the Liconic") - match = re.search(r"_(\d+)mm", rack.model) - if match: - site_height = int(match.group(1)) - site_num = int(rack.model.split("_")[-1]) - if site_height not in LICONIC_SITE_HEIGHT_TO_STEPS: - raise ValueError( - f"Unknown site height {site_height}mm - not in LICONIC_SITE_HEIGHT_TO_STEPS" - ) - return LICONIC_SITE_HEIGHT_TO_STEPS[site_height], site_num - raise ValueError( - f"Could not parse site height and pos num from PlateCarrier model: {rack.model}" - ) + await self.io.stop() + raise TimeoutError(f"PLC did not signal ready within {self.start_timeout} seconds") async def stop(self): await self.io.stop() + await DeviceBackend.stop(self) async def set_racks(self, racks: List[PlateCarrier]): - await super().set_racks(racks) + self._racks = racks warnings.warn("Liconic racks need to be configured manually on each setup") - async def initialize(self): - await self._send_command("ST 1900") - await self._send_command("ST 1801") - await self._wait_ready() + # -- AutomatedRetrievalBackend -- - async def open_door(self): - await self._send_command("ST 1901") - await self._wait_ready() - - async def close_door(self): - await self._send_command("ST 1902") - await self._wait_ready() - - async def fetch_plate_to_loading_tray( - self, plate: Plate, read_barcode: bool = False, **backend_kwargs - ): - """Fetch a plate from the incubator to the loading tray.""" + async def fetch_plate_to_loading_tray(self, plate: Plate): site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) - await self._send_command(f"WR DM0 {m}") # carousel number - await self._send_command(f"WR DM23 {step_size}") # motor step size - await self._send_command(f"WR DM25 {pos_num}") # number of positions in cassette - await self._send_command(f"WR DM5 {n}") # plate position in carousel + await self._send_command(f"WR DM0 {m}") + await self._send_command(f"WR DM23 {step_size}") + await self._send_command(f"WR DM25 {pos_num}") + await self._send_command(f"WR DM5 {n}") - if read_barcode: - plate.barcode = await self.read_barcode_inline(m, n) - - await self._send_command("ST 1905") # plate to transfer station + await self._send_command("ST 1905") await self._wait_ready() - await self._send_command("ST 1903") # terminate access + await self._send_command("ST 1903") - async def take_in_plate( - self, plate: Plate, site: PlateHolder, read_barcode: bool = False, **backend_kwargs - ): - """Take in a plate from the loading tray to the incubator.""" + async def store_plate(self, plate: Plate, site: PlateHolder): m, n = self._site_to_m_n(site) step_size, pos_num = self._carrier_to_steps_pos(site) - await self._send_command(f"WR DM0 {m}") # carousel number - await self._send_command(f"WR DM23 {step_size}") # motor step size - await self._send_command(f"WR DM25 {pos_num}") # number of positions in cassette - await self._send_command(f"WR DM5 {n}") # plate position in cassette - await self._send_command("ST 1904") # plate from transfer station + await self._send_command(f"WR DM0 {m}") + await self._send_command(f"WR DM23 {step_size}") + await self._send_command(f"WR DM25 {pos_num}") + await self._send_command(f"WR DM5 {n}") + await self._send_command("ST 1904") await self._wait_ready() + await self._send_command("ST 1903") - if read_barcode: - plate.barcode = await self.read_barcode_inline(m, n) + # -- TemperatureControllerBackend -- - await self._send_command("ST 1903") # terminate access + @property + def supports_active_cooling(self) -> bool: + return self.model.has_active_cooling - async def move_position_to_position( - self, plate: Plate, dest_site: PlateHolder, read_barcode: bool = False - ): - """Move plate from one internal position to another""" - orig_site = plate.parent - assert isinstance(orig_site, PlateHolder) - assert isinstance(dest_site, PlateHolder) + async def set_temperature(self, temperature: float): + if not self.model.has_temperature_control: + raise NotImplementedError("Climate control is not supported on this model") + temp_value = int(temperature * 10) + temp_str = str(temp_value).zfill(5) + await self._send_command(f"WR DM890 {temp_str}") + await self._wait_ready() - if dest_site.resource is not None: - raise RuntimeError(f"Position {dest_site} already has a plate assigned!") + async def get_current_temperature(self) -> float: + if not self.model.has_temperature_control: + raise NotImplementedError("Climate control is not supported on this model") + resp = await self._send_command("RD DM982") + try: + return int(resp) / 10.0 + except ValueError: + raise RuntimeError(f"Invalid temperature value received from incubator: {resp!r}") - orig_m, orig_n = self._site_to_m_n(orig_site) # origin cassette # and plate position # - dest_m, dest_n = self._site_to_m_n(dest_site) # destination cassette # and plate position # + async def deactivate(self): + pass # no-op - await self._send_command(f"WR DM0 {orig_m}") # origin cassette # - orig_step_size, orig_pos_num = self._carrier_to_steps_pos(orig_site) - dest_step_size, dest_pos_num = self._carrier_to_steps_pos(dest_site) + # -- HumidityControllerBackend -- - await self._send_command(f"WR DM0 {orig_m}") # carousel number - await self._send_command(f"WR DM23 {orig_step_size}") # motor step size - await self._send_command(f"WR DM25 {orig_pos_num}") # number of positions in cassette - await self._send_command(f"WR DM5 {orig_n}") # origin plate position # + @property + def supports_humidity_control(self) -> bool: + return self.model.has_humidity_control - if read_barcode: - plate.barcode = await self.read_barcode_inline(orig_m, orig_n) + async def set_humidity(self, humidity: float): + if not self.model.has_temperature_control: + raise NotImplementedError("Climate control is not supported on this model") + humidity_val = int(humidity * 1000) + await self._send_command(f"WR DM893 {str(humidity_val).zfill(5)}") + await self._wait_ready() - await self._send_command("ST 1908") # pick plate from origin position + async def get_current_humidity(self) -> float: + if not self.model.has_temperature_control: + raise NotImplementedError("Climate control is not supported on this model") + resp = await self._send_command("RD DM983") + try: + return int(resp) / 1000.0 + except ValueError: + raise RuntimeError(f"Invalid humidity value received from incubator: {resp!r}") - await self._wait_ready() + # -- ShakerBackend -- - if orig_m != dest_m: - await self._send_command(f"WR DM0 {dest_m}") # destination cassette # if different - await self._send_command(f"WR DM23 {dest_step_size}") # motor step size - await self._send_command(f"WR DM25 {dest_pos_num}") # number of positions in cassette - await self._send_command(f"WR DM5 {dest_n}") # destination plate position # - await self._send_command("ST 1909") # place plate in destination position + @property + def supports_locking(self) -> bool: + return False - await self._wait_ready() - await self._send_command("ST 1903") # terminate access + async def lock_plate(self): + raise NotImplementedError("Liconic does not support plate locking") - async def read_barcode_inline(self, cassette: int, plt_position: int) -> Barcode: - if self.barcode_scanner is None: - raise RuntimeError("Barcode scanner not configured for this incubator instance") + async def unlock_plate(self): + raise NotImplementedError("Liconic does not support plate locking") - await self._send_command("ST 1910") # move shovel to barcode reading position + async def start_shaking(self, speed: float): + if speed < 1.0 or speed > 50.0: + raise ValueError("Shaking frequency must be between 1.0 and 50.0 Hz") + frequency_value = int(speed * 10) + await self._send_command(f"WR DM39 {str(frequency_value).zfill(5)}") + await self._send_command("ST 1913") await self._wait_ready() - barcode = await self.barcode_scanner.scan() - logger.info( - f"Read barcode from plate at cassette {cassette}, position {plt_position}: {barcode.data}" - ) - reset = await self._send_command("RS 1910") # move shovel back to normal position - if reset != "OK": - raise RuntimeError("Failed to reset shovel position after barcode reading") + + async def stop_shaking(self): + await self._send_command("RS 1913") await self._wait_ready() - return barcode + + # -- Device-specific methods -- + + def _site_to_m_n(self, site: PlateHolder) -> Tuple[int, int]: + rack = site.parent + assert isinstance(rack, PlateCarrier), "Site not in rack" + assert self._racks is not None, "Racks not set" + rack_idx = self._racks.index(rack) + 1 + site_idx = next(idx for idx, s in rack.sites.items() if s == site) + 1 + return rack_idx, site_idx + + def _carrier_to_steps_pos(self, site: PlateHolder) -> Tuple[int, int]: + rack = site.parent + assert isinstance(rack, PlateCarrier), "Site not in rack" + assert self._racks is not None, "Racks not set" + if rack.model is None or not rack.model.startswith("liconic"): + raise ValueError(f"The plate carrier used: {rack.model} is not compatible with the Liconic") + match = re.search(r"_(\d+)mm", rack.model) + if match: + site_height = int(match.group(1)) + site_num = int(rack.model.split("_")[-1]) + if site_height not in LICONIC_SITE_HEIGHT_TO_STEPS: + raise ValueError( + f"Unknown site height {site_height}mm - not in LICONIC_SITE_HEIGHT_TO_STEPS" + ) + return LICONIC_SITE_HEIGHT_TO_STEPS[site_height], site_num + raise ValueError( + f"Could not parse site height and pos num from PlateCarrier model: {rack.model}" + ) async def _send_command(self, command: str) -> str: - """ - Send an ASCII command to the Liconic PLC over serial and return the response. - """ cmd = command.strip() + "\r" - logger.debug(f"Sending command to Liconic PLC: {cmd!r}") + logger.debug("Sending command to Liconic PLC: %r", cmd) await self.io.write(cmd.encode(self.serial_message_encoding)) resp = (await self.io.read(128)).decode(self.serial_message_encoding) if not resp: raise RuntimeError(f"No response from Liconic PLC for command {command!r}") resp = resp.strip() if resp.startswith("E"): - logger.error(f"Command {command} failed with {resp}") + logger.error("Command %s failed with %s", command, resp) for member in ControllerError: if resp == member.value: cls, msg = controller_error_map[member] @@ -304,9 +292,6 @@ async def _send_command(self, command: str) -> str: return resp async def _wait_plate_ready(self, timeout: int = 60): - """ - Poll the plate-ready flag (RD 1914) until it is set, or timeout is reached. - """ start = time.time() deadline = start + timeout while time.time() < deadline: @@ -317,10 +302,6 @@ async def _wait_plate_ready(self, timeout: int = 60): raise TimeoutError(f"Plate did not become ready within {timeout} seconds") async def _wait_ready(self, timeout: int = 60): - """ - Poll the ready-flag (RD 1915) until it is set. If timeout is reached - the error flag is read and if true aka "1" then the error register is read. - """ start = time.time() deadline = start + timeout while time.time() < deadline: @@ -338,183 +319,143 @@ async def _wait_ready(self, timeout: int = 60): raise RuntimeError(f"Liconic Handler in unknown error state with memory showing {error}") raise TimeoutError(f"Incubator did not become ready within {timeout} seconds") - async def set_temperature(self, temperature: float): - """Set the temperature of the incubator in degrees Celsius. Using command WR DM890 ttttt - where ttttt is temperature in 0.1 degrees Celsius (e.g. 37.0C = 370)""" - if self.model.value.split("_")[-1] == "NC": - raise NotImplementedError("Climate control is not supported on this model") - - temp_value = int(temperature * 10) - temp_str = str(temp_value).zfill(5) - await self._send_command(f"WR DM890 {temp_str}") + async def initialize(self): + await self._send_command("ST 1900") + await self._send_command("ST 1801") await self._wait_ready() - async def get_temperature(self) -> float: - """Get the temperature of the incubator in degrees Celsius. Using command RD DM982""" - if self.model.value.split("_")[-1] == "NC": - raise NotImplementedError("Climate control is not supported on this model") + async def open_door(self): + await self._send_command("ST 1901") + await self._wait_ready() - resp = await self._send_command("RD DM982") - try: - temp_value = int(resp) - temperature = temp_value / 10.0 - return temperature - except ValueError: - raise RuntimeError(f"Invalid temperature value received from incubator: {resp!r}") + async def close_door(self): + await self._send_command("ST 1902") + await self._wait_ready() - async def shaker_status(self) -> int: - """Determines whether the shaker is ON (1) or OFF (0). + async def move_position_to_position(self, plate: Plate, dest_site: PlateHolder): + orig_site = plate.parent + assert isinstance(orig_site, PlateHolder) + assert isinstance(dest_site, PlateHolder) - UNTESTED. Unsure if 1 means ON and 0 means OFF, needs to be confirmed.""" - # TODO: Missing PLC command - need to determine correct command from Liconic documentation - raise NotImplementedError("shaker_status command not yet implemented") + if dest_site.resource is not None: + raise RuntimeError(f"Position {dest_site} already has a plate assigned!") - async def get_shaker_speed(self) -> float: - """Gets the current shaker speed in Hz, default = 25. + orig_m, orig_n = self._site_to_m_n(orig_site) + dest_m, dest_n = self._site_to_m_n(dest_site) + orig_step_size, orig_pos_num = self._carrier_to_steps_pos(orig_site) + dest_step_size, dest_pos_num = self._carrier_to_steps_pos(dest_site) - UNTESTED. Unsure if Liconic returns 00250 for 25 or 00025. Assuming former.""" - speed_val = await self._send_command("RD DM39") - speed = int(speed_val) / 10.0 + await self._send_command(f"WR DM0 {orig_m}") + await self._send_command(f"WR DM23 {orig_step_size}") + await self._send_command(f"WR DM25 {orig_pos_num}") + await self._send_command(f"WR DM5 {orig_n}") + await self._send_command("ST 1908") await self._wait_ready() - return speed - async def start_shaking(self, frequency): - """Start shaking. Must be between 1 and 50 Hz. Frequency by default is 10 Hz. Uses command - ST 1913. - - UNTESTED. Unsure if WR DM39 00250 sets 25 Hz or if WR DM39 00025 does. Assuming former.""" - if frequency < 1.0 or frequency > 50.0: - raise ValueError("Shaking frequency must be between 1.0 and 50.0 Hz") - frequency_value = int(frequency * 10) # PLC expects 0.1 Hz units: 25 Hz -> 250 - await self._send_command(f"WR DM39 {str(frequency_value).zfill(5)}") - await self._send_command("ST 1913") + if orig_m != dest_m: + await self._send_command(f"WR DM0 {dest_m}") + await self._send_command(f"WR DM23 {dest_step_size}") + await self._send_command(f"WR DM25 {dest_pos_num}") + await self._send_command(f"WR DM5 {dest_n}") + await self._send_command("ST 1909") await self._wait_ready() + await self._send_command("ST 1903") - async def stop_shaking(self): - """Stop shaking. Uses command RS 1913. - - UNTESTED.""" - await self._send_command("RS 1913") + async def read_barcode_inline( + self, cassette: int, plt_position: int, barcode_scanner: BarcodeScannerBackend + ) -> Barcode: + await self._send_command("ST 1910") + await self._wait_ready() + barcode = await barcode_scanner.scan_barcode() + logger.info( + "Read barcode from plate at cassette %d, position %d: %s", + cassette, + plt_position, + barcode.data, + ) + reset = await self._send_command("RS 1910") + if reset != "OK": + raise RuntimeError("Failed to reset shovel position after barcode reading") await self._wait_ready() + return barcode + + async def scan_barcode( + self, site: PlateHolder, barcode_scanner: BarcodeScannerBackend + ) -> Barcode: + m, n = self._site_to_m_n(site) + step_size, pos_num = self._carrier_to_steps_pos(site) + await self._send_command(f"WR DM0 {m}") + await self._send_command(f"WR DM23 {step_size}") + await self._send_command(f"WR DM25 {pos_num}") + await self._send_command(f"WR DM5 {n}") + await self._send_command("ST 1910") + barcode = await barcode_scanner.scan_barcode() + logger.info("Scanned barcode: %s", barcode.data) + return barcode async def get_target_temperature(self) -> float: - """Get the set value temperature of the incubator in degrees Celsius.""" - if self.model.value.split("_")[-1] == "NC": + if not self.model.has_temperature_control: raise NotImplementedError("Climate control is not supported on this model") - resp = await self._send_command("RD DM890") try: - temp_value = int(resp) - temperature = temp_value / 10.0 - return temperature + return int(resp) / 10.0 except ValueError: raise RuntimeError(f"Invalid set temperature value received from incubator: {resp!r}") - async def set_humidity(self, humidity: float): - """Set the humidity of the incubator as a fraction (0.0 to 1.0).""" - if self.model.value.split("_")[-1] == "NC": - raise NotImplementedError("Climate control is not supported on this model") - - humidity_val = int(humidity * 1000) # PLC uses 0.1% units: 0.9 fraction -> 900 -> 90.0% - await self._send_command(f"WR DM893 {str(humidity_val).zfill(5)}") - await self._wait_ready() - - async def get_humidity(self) -> float: - """Get the actual humidity of the incubator as a fraction (0.0 to 1.0).""" - if self.model.value.split("_")[-1] == "NC": - raise NotImplementedError("Climate control is not supported on this model") - - resp = await self._send_command("RD DM983") - try: - humidity_value = int(resp) - humidity = humidity_value / 1000.0 # PLC uses 0.1% units: 900 -> 0.9 fraction - return humidity - except ValueError: - raise RuntimeError(f"Invalid humidity value received from incubator: {resp!r}") - async def get_target_humidity(self) -> float: - """Get the set value humidity of the incubator as a fraction (0.0 to 1.0).""" - if self.model.value.split("_")[-1] == "NC": + if not self.model.has_temperature_control: raise NotImplementedError("Climate control is not supported on this model") - resp = await self._send_command("RD DM893") try: - humidity_value = int(resp) - humidity = humidity_value / 1000.0 # PLC uses 0.1% units: 900 -> 0.9 fraction - return humidity + return int(resp) / 1000.0 except ValueError: raise RuntimeError(f"Invalid set humidity value received from incubator: {resp!r}") - async def set_co2_level(self, co2_level: float): - """Set the CO2 level of the incubator as a fraction (0.0 to 1.0). PLC uses 1/100% vol units - (e.g. 500 = 5.0%), so 0.05 fraction -> 500. + async def get_shaker_speed(self) -> float: + speed_val = await self._send_command("RD DM39") + speed = int(speed_val) / 10.0 + await self._wait_ready() + return speed - UNTESTED.""" - co2_val = int(co2_level * 10000) # PLC uses 0.01% units: 0.05 fraction -> 500 -> 5.0% + async def set_co2_level(self, co2_level: float): + co2_val = int(co2_level * 10000) await self._send_command(f"WR DM894 {str(co2_val).zfill(5)}") await self._wait_ready() async def get_co2_level(self) -> float: - """Get the CO2 level of the incubator as a fraction (0.0 to 1.0). - - UNTESTED.""" resp = await self._send_command("RD DM984") try: - co2_value = int(resp) - co2 = co2_value / 10000.0 # PLC uses 0.01% units: 500 -> 0.05 fraction - return co2 + return int(resp) / 10000.0 except ValueError: raise RuntimeError(f"Invalid co2 value received from incubator: {resp!r}") async def get_target_co2_level(self) -> float: - """Get the set value CO2 level of the incubator as a fraction (0.0 to 1.0). - - UNTESTED.""" resp = await self._send_command("RD DM894") try: - co2_set_value = int(resp) - co2 = co2_set_value / 10000.0 # PLC uses 0.01% units: 500 -> 0.05 fraction - return co2 + return int(resp) / 10000.0 except ValueError: raise RuntimeError(f"Invalid co2 set value received from incubator: {resp!r}") async def set_n2_level(self, n2_level: float): - """Set the N2 level of the incubator as a fraction (0.0 to 1.0). - - UNTESTED.""" - n2_val = int(n2_level * 10000) # PLC uses 0.01% units: 0.9 fraction -> 9000 -> 90.0% + n2_val = int(n2_level * 10000) await self._send_command(f"WR DM895 {str(n2_val).zfill(5)}") await self._wait_ready() async def get_n2_level(self) -> float: - """Get the N2 level of the incubator as a fraction (0.0 to 1.0). - - UNTESTED.""" resp = await self._send_command("RD DM985") try: - n2_value = int(resp) - n2 = n2_value / 10000.0 # PLC uses 0.01% units: 9000 -> 0.9 fraction - return n2 + return int(resp) / 10000.0 except ValueError: raise RuntimeError(f"Invalid N2 value received from incubator: {resp!r}") async def get_target_n2_level(self) -> float: - """Get the set value N2 level of the incubator as a fraction (0.0 to 1.0). - - UNTESTED.""" resp = await self._send_command("RD DM895") try: - n2_set_value = int(resp) - n2 = n2_set_value / 10000.0 # PLC uses 0.01% units: 9000 -> 0.9 fraction - return n2 + return int(resp) / 10000.0 except ValueError: raise RuntimeError(f"Invalid N2 set value received from incubator: {resp!r}") async def turn_swap_station(self, home: bool): - """Turn the swap station of the incubator. If home is True, turn to home position. - - UNTESTED. Unsure what RD 1912 returns (is 1 home or swapped?). Another avenue is to read the - first byte of T16 or T17 but don't have ability to test.""" resp = await self._send_command("RD 1912") if home and resp == "1": await self._send_command("RS 1912") @@ -522,10 +463,6 @@ async def turn_swap_station(self, home: bool): await self._send_command("ST 1912") async def check_shovel_sensor(self) -> bool: - """Activate shovel transfer sensor (ST 1911, off by default on HT units), wait 0.1 seconds, - then check if the shovel plate sensor is activated. - - UNTESTED.""" await self._send_command("ST 1911") await asyncio.sleep(0.1) resp = await self._send_command("RD 1812") @@ -537,9 +474,6 @@ async def check_shovel_sensor(self) -> bool: raise RuntimeError(f"Unexpected response from incubator read shovel sensor: {resp!r}") async def check_transfer_sensor(self) -> bool: - """Check if the transfer plate sensor is activated. - - UNTESTED.""" resp = await self._send_command("RD 1813") if resp == "1": return True @@ -549,9 +483,6 @@ async def check_transfer_sensor(self) -> bool: raise RuntimeError(f"Unexpected response from read transfer station sensor: {resp!r}") async def check_second_transfer_sensor(self) -> bool: - """Check if the second transfer plate sensor is activated. - - UNTESTED.""" resp = await self._send_command("RD 1807") if resp == "1": return True @@ -560,27 +491,9 @@ async def check_second_transfer_sensor(self) -> bool: else: raise RuntimeError(f"Unexpected response from read 2nd transfer station sensor: {resp!r}") - async def scan_barcode(self, site: PlateHolder) -> Barcode: - """Scan a barcode using the internal barcode reader.""" - if self.barcode_scanner is None: - raise RuntimeError("Barcode scanner not configured for this incubator instance") - - m, n = self._site_to_m_n(site) - step_size, pos_num = self._carrier_to_steps_pos(site) - - await self._send_command(f"WR DM0 {m}") # carousel number - await self._send_command(f"WR DM23 {step_size}") # pitch of plate in mm - await self._send_command(f"WR DM25 {pos_num}") # plate - await self._send_command(f"WR DM5 {n}") # plate position in carousel - await self._send_command("ST 1910") # move shovel to barcode reading position - - barcode = await self.barcode_scanner.scan() - logger.info(f"Scanned barcode: {barcode.data}") - return barcode - def serialize(self) -> dict: return { - **super().serialize(), + **DeviceBackend.serialize(self), "port": self.io.port, "model": self.model.value, } diff --git a/pylabrobot/storage/liconic/constants.py b/pylabrobot/liconic/constants.py similarity index 67% rename from pylabrobot/storage/liconic/constants.py rename to pylabrobot/liconic/constants.py index b9ae563a061..588de789560 100644 --- a/pylabrobot/storage/liconic/constants.py +++ b/pylabrobot/liconic/constants.py @@ -2,65 +2,82 @@ class LiconicType(Enum): - STX44_IC = "STX44_IC" # incubator - STX44_HC = "STX44_HC" # humid cooler - STX44_DC2 = "STX44_DC2" # dry storage - STX44_HR = "STX44_HR" # humid wide range - STX44_DR2 = "STX44_DR2" # dry wide range - STX44_AR = "STX44_AR" # humidity controlled - STX44_DF = "STX44_DF" # deep freezer - STX44_NC = "STX44_NC" # no climate - STX44_DH = "STX44_DH" # dry humid - - STX110_IC = "STX110_IC" # incubator - STX110_HC = "STX110_HC" # humid cooler - STX110_DC2 = "STX110_DC2" # dry storage - STX110_HR = "STX110_HR" # humid wide range - STX110_DR2 = "STX110_DR2" # dry wide range - STX110_AR = "STX110_AR" # humidity controlled - STX110_DF = "STX110_DF" # deep freezer - STX110_NC = "STX110_NC" # no climate - STX110_DH = "STX110_DH" # dry humid - - STX220_IC = "STX220_IC" # incubator - STX220_HC = "STX220_HC" # humid cooler - STX220_DC2 = "STX220_DC2" # dry storage - STX220_HR = "STX220_HR" # humid wide range - STX220_DR2 = "STX220_DR2" # dry wide range - STX220_AR = "STX220_AR" # humidity controlled - STX220_DF = "STX220_DF" # deep freezer - STX220_NC = "STX220_NC" # no climate - STX220_DH = "STX220_DH" # dry humid - - STX280_IC = "STX280_IC" # incubator - STX280_HC = "STX280_HC" # humid cooler - STX280_DC2 = "STX280_DC2" # dry storage - STX280_HR = "STX280_HR" # humid wide range - STX280_DR2 = "STX280_DR2" # dry wide range - STX280_AR = "STX280_AR" # humidity controlled - STX280_DF = "STX280_DF" # deep freezer - STX280_NC = "STX280_NC" # no climate - STX280_DH = "STX280_DH" # dry humid - - STX500_IC = "STX500_IC" # incubator - STX500_HC = "STX500_HC" # humid cooler - STX500_DC2 = "STX500_DC2" # dry storage - STX500_HR = "STX500_HR" # humid wide range - STX500_DR2 = "STX500_DR2" # dry wide range - STX500_AR = "STX500_AR" # humidity controlled - STX500_DF = "STX500_DF" # deep freezer - STX500_NC = "STX500_NC" # no climate - STX500_DH = "STX500_DH" # dry humid - - STX1000_IC = "STX1000_IC" # incubator - STX1000_HC = "STX1000_HC" # humid cooler - STX1000_DC2 = "STX1000_DC2" # dry storage - STX1000_HR = "STX1000_HR" # humid wide range - STX1000_DR2 = "STX1000_DR2" # dry wide range - STX1000_AR = "STX1000_AR" # humidity controlled - STX1000_DF = "STX1000_DF" # deep freezer - STX1000_NC = "STX1000_NC" # no climate - STX1000_DH = "STX1000_DH" # dry humid + STX44_IC = "STX44_IC" + STX44_HC = "STX44_HC" + STX44_DC2 = "STX44_DC2" + STX44_HR = "STX44_HR" + STX44_DR2 = "STX44_DR2" + STX44_AR = "STX44_AR" + STX44_DF = "STX44_DF" + STX44_NC = "STX44_NC" + STX44_DH = "STX44_DH" + + STX110_IC = "STX110_IC" + STX110_HC = "STX110_HC" + STX110_DC2 = "STX110_DC2" + STX110_HR = "STX110_HR" + STX110_DR2 = "STX110_DR2" + STX110_AR = "STX110_AR" + STX110_DF = "STX110_DF" + STX110_NC = "STX110_NC" + STX110_DH = "STX110_DH" + + STX220_IC = "STX220_IC" + STX220_HC = "STX220_HC" + STX220_DC2 = "STX220_DC2" + STX220_HR = "STX220_HR" + STX220_DR2 = "STX220_DR2" + STX220_AR = "STX220_AR" + STX220_DF = "STX220_DF" + STX220_NC = "STX220_NC" + STX220_DH = "STX220_DH" + + STX280_IC = "STX280_IC" + STX280_HC = "STX280_HC" + STX280_DC2 = "STX280_DC2" + STX280_HR = "STX280_HR" + STX280_DR2 = "STX280_DR2" + STX280_AR = "STX280_AR" + STX280_DF = "STX280_DF" + STX280_NC = "STX280_NC" + STX280_DH = "STX280_DH" + + STX500_IC = "STX500_IC" + STX500_HC = "STX500_HC" + STX500_DC2 = "STX500_DC2" + STX500_HR = "STX500_HR" + STX500_DR2 = "STX500_DR2" + STX500_AR = "STX500_AR" + STX500_DF = "STX500_DF" + STX500_NC = "STX500_NC" + STX500_DH = "STX500_DH" + + STX1000_IC = "STX1000_IC" + STX1000_HC = "STX1000_HC" + STX1000_DC2 = "STX1000_DC2" + STX1000_HR = "STX1000_HR" + STX1000_DR2 = "STX1000_DR2" + STX1000_AR = "STX1000_AR" + STX1000_DF = "STX1000_DF" + STX1000_NC = "STX1000_NC" + STX1000_DH = "STX1000_DH" + + @property + def climate_suffix(self) -> str: + return self.value.split("_")[-1] + + @property + def has_temperature_control(self) -> bool: + return self.climate_suffix != "NC" + + @property + def has_humidity_control(self) -> bool: + """Independent humidity control (not just temperature-dependent).""" + return self.climate_suffix in {"DC2", "DR2", "AR", "DH"} + + @property + def has_active_cooling(self) -> bool: + return self.climate_suffix in {"HC", "HR", "DF"} class ControllerError(Enum): diff --git a/pylabrobot/storage/liconic/errors.py b/pylabrobot/liconic/errors.py similarity index 99% rename from pylabrobot/storage/liconic/errors.py rename to pylabrobot/liconic/errors.py index 0025661ca97..7fed573e98d 100644 --- a/pylabrobot/storage/liconic/errors.py +++ b/pylabrobot/liconic/errors.py @@ -1,6 +1,6 @@ from typing import Dict, Tuple, Type -from pylabrobot.storage.liconic.constants import ControllerError, HandlingError +from pylabrobot.liconic.constants import ControllerError, HandlingError class LiconicControllerRelayError(Exception): diff --git a/pylabrobot/liconic/liconic.py b/pylabrobot/liconic/liconic.py new file mode 100644 index 00000000000..f8b0daa7553 --- /dev/null +++ b/pylabrobot/liconic/liconic.py @@ -0,0 +1,208 @@ +import random +from typing import List, Literal, Optional, Union, cast + +from pylabrobot.capabilities.automated_retrieval import AutomatedRetrievalCapability +from pylabrobot.capabilities.barcode_scanning import BarcodeScanningCapability +from pylabrobot.capabilities.humidity_controlling import HumidityControlCapability +from pylabrobot.capabilities.shaking import ShakingCapability +from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.device import Device +from pylabrobot.resources import ( + Coordinate, + Plate, + PlateCarrier, + PlateHolder, + Resource, + ResourceNotFoundError, + Rotation, +) + +from .backend import LiconicBackend, LiconicType + + +class NoFreeSiteError(Exception): + pass + + +class Liconic(Resource, Device): + def __init__( + self, + name: str, + liconic_model: Union[LiconicType, str], + port: str, + racks: List[PlateCarrier], + loading_tray_location: Coordinate, + has_shaker: bool = False, + barcode_scanner: Optional[BarcodeScanningCapability] = None, + size_x: float = 0, + size_y: float = 0, + size_z: float = 0, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = None, + ): + if isinstance(liconic_model, str): + liconic_model = LiconicType(liconic_model) + + backend = LiconicBackend(model=liconic_model, port=port) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + ) + Device.__init__(self, backend=backend) + self._backend: LiconicBackend = backend + + self.loading_tray = PlateHolder( + name=f"{name}_tray", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0 + ) + self.assign_child_resource(self.loading_tray, location=loading_tray_location) + + self._racks = racks + for rack in self._racks: + self.assign_child_resource(rack, location=None) + + self.retrieval = AutomatedRetrievalCapability(backend=backend) + self.tc = ( + TemperatureControlCapability(backend=backend) + if liconic_model.has_temperature_control + else None + ) + self.humidity_controller = ( + HumidityControlCapability(backend=backend) if liconic_model.has_humidity_control else None + ) + self.shaker = ShakingCapability(backend=backend) if has_shaker else None + self.barcode_scanner = barcode_scanner + + self._capabilities = [ + c + for c in [ + self.retrieval, + self.tc, + self.humidity_controller, + self.shaker, + self.barcode_scanner, + ] + if c is not None + ] + + @property + def racks(self) -> List[PlateCarrier]: + return self._racks + + async def setup(self, **backend_kwargs): + if self.barcode_scanner is not None: + await self.barcode_scanner.backend.setup() + await super().setup() + await self._backend.set_racks(self._racks) + + async def stop(self): + await super().stop() + if self.barcode_scanner is not None: + await self.barcode_scanner.backend.stop() + + def get_num_free_sites(self) -> int: + return sum(len(rack.get_free_sites()) for rack in self._racks) + + def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: + for rack in self._racks: + for site in rack.sites.values(): + if site.resource is not None and site.resource.name == plate_name: + return site + raise ResourceNotFoundError(f"Plate {plate_name} not found in '{self.name}'") + + async def fetch_plate_to_loading_tray(self, plate_name: str) -> Plate: + site = self.get_site_by_plate_name(plate_name) + plate = site.resource + assert plate is not None + await self.retrieval.fetch_plate_to_loading_tray(plate) + plate.unassign() + self.loading_tray.assign_child_resource(plate) + return plate + + def _find_available_sites_sorted(self, plate: Plate) -> List[PlateHolder]: + def _plate_height(p: Plate): + if p.has_lid(): + return p.get_size_z() + 3 + return p.get_size_z() + + available = [ + site + for rack in self._racks + for site in rack.get_free_sites() + if site.get_size_z() >= _plate_height(plate) + ] + if len(available) == 0: + raise NoFreeSiteError(f"No free site found in '{self.name}' for plate '{plate.name}'") + return sorted(available, key=lambda site: site.get_size_z()) + + def find_smallest_site_for_plate(self, plate: Plate) -> PlateHolder: + return self._find_available_sites_sorted(plate)[0] + + def find_random_site(self, plate: Plate) -> PlateHolder: + return random.choice(self._find_available_sites_sorted(plate)) + + async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smallest"]]): + plate = cast(Plate, self.loading_tray.resource) + if plate is None: + raise ResourceNotFoundError(f"No plate on the loading tray of '{self.name}'") + + if site == "random": + site = self.find_random_site(plate) + elif site == "smallest": + site = self.find_smallest_site_for_plate(plate) + elif isinstance(site, PlateHolder): + if site not in self._find_available_sites_sorted(plate): + raise ValueError(f"Site {site.name} is not available for plate {plate.name}") + else: + raise ValueError(f"Invalid site: {site}") + await self.retrieval.store_plate(plate, site) + plate.unassign() + site.assign_child_resource(plate) + + def summary(self) -> str: + def create_pretty_table(header, *columns) -> str: + col_widths = [ + max(len(str(item)) for item in [header[i]] + list(columns[i])) for i in range(len(header)) + ] + + def format_row(row, border="|") -> str: + return ( + f"{border} " + + " | ".join(f"{str(row[i]).ljust(col_widths[i])}" for i in range(len(row))) + + f" {border}" + ) + + def separator_line(cross: str = "+", line: str = "-") -> str: + return cross + cross.join(line * (width + 2) for width in col_widths) + cross + + table = [] + table.append(separator_line()) + table.append(format_row(header)) + table.append(separator_line()) + for row in zip(*columns): + table.append(format_row(row)) + table.append(separator_line()) + return "\n".join(table) + + header = [f"Rack {i}" for i in range(len(self._racks))] + sites = [ + [site.resource.name if site.resource else "" for site in reversed(rack.sites.values())] + for rack in self._racks + ] + return create_pretty_table(header, *sites) + + def serialize(self): + from pylabrobot.serializer import serialize + + return { + **Device.serialize(self), + **Resource.serialize(self), + "racks": [rack.serialize() for rack in self._racks], + "loading_tray_location": serialize(self.loading_tray.location), + } diff --git a/pylabrobot/storage/liconic/racks.py b/pylabrobot/liconic/racks.py similarity index 100% rename from pylabrobot/storage/liconic/racks.py rename to pylabrobot/liconic/racks.py diff --git a/pylabrobot/mettler_toledo/__init__.py b/pylabrobot/mettler_toledo/__init__.py new file mode 100644 index 00000000000..9b894ec6afa --- /dev/null +++ b/pylabrobot/mettler_toledo/__init__.py @@ -0,0 +1 @@ +from .mettler_toledo import MettlerToledoError, MettlerToledoWXS205SDUBackend diff --git a/pylabrobot/scales/mettler_toledo_backend.py b/pylabrobot/mettler_toledo/mettler_toledo.py similarity index 85% rename from pylabrobot/scales/mettler_toledo_backend.py rename to pylabrobot/mettler_toledo/mettler_toledo.py index 7c1b8923969..74e702cddf1 100644 --- a/pylabrobot/scales/mettler_toledo_backend.py +++ b/pylabrobot/mettler_toledo/mettler_toledo.py @@ -3,11 +3,11 @@ import asyncio import logging import time -import warnings from typing import List, Literal, Optional, Union +from pylabrobot.capabilities.weighing import ScaleBackend +from pylabrobot.device import DeviceBackend from pylabrobot.io.serial import Serial -from pylabrobot.scales.scale_backend import ScaleBackend logger = logging.getLogger("pylabrobot") @@ -164,7 +164,7 @@ def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6 super().__init__() self.io = Serial( - human_readable_device_name="Mettler Toledo Scale", + human_readable_device_name="Mettler Toledo WXS205SDU", port=port, vid=vid, pid=pid, @@ -173,7 +173,7 @@ def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6 ) async def setup(self) -> None: - # Core state + await DeviceBackend.setup(self) await self.io.setup() # set output unit to grams @@ -181,9 +181,9 @@ async def setup(self) -> None: # Handshake: parse requested serial number self.serial_number = await self.request_serial_number() - # TODO: verify serial number pattern async def stop(self) -> None: + await DeviceBackend.stop(self) await self.io.stop() def serialize(self) -> dict: @@ -465,77 +465,3 @@ async def set_display_text(self, text: str) -> MettlerToledoResponse: async def set_weight_display(self) -> MettlerToledoResponse: """Return the display to the normal weight display.""" return await self.send_command("DW") - - # # # Deprecated alias with warning # # # - - # # TODO: remove 2026-03 (giving people >2 months to update) - - async def get_serial_number(self) -> str: - """Deprecated: Use request_serial_number() instead.""" - warnings.warn( - "get_serial_number() is deprecated and will be removed in 2026-03. " - "Use request_serial_number() instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.request_serial_number() - - async def get_tare_weight(self) -> float: - """Deprecated: Use request_tare_weight() instead.""" - warnings.warn( - "get_tare_weight() is deprecated and will be removed in 2026-03. " - "Use request_tare_weight() instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.request_tare_weight() - - async def get_stable_weight(self) -> float: - """Deprecated: Use read_stable_weight() instead.""" - warnings.warn( - "get_stable_weight() is deprecated and will be removed in 2026-03. " - "Use read_stable_weight() instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.read_stable_weight() - - async def get_dynamic_weight(self, timeout: float) -> float: - """Deprecated: Use read_dynamic_weight() instead.""" - warnings.warn( - "get_dynamic_weight() is deprecated and will be removed in 2026-03. " - "Use read_dynamic_weight() instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.read_dynamic_weight(timeout) - - async def get_weight_value_immediately(self) -> float: - """Deprecated: Use read_weight_value_immediately() instead.""" - warnings.warn( - "get_weight_value_immediately() is deprecated and will be removed in 2026-03. " - "Use read_weight_value_immediately() instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.read_weight_value_immediately() - - async def get_weight(self, timeout: Union[Literal["stable"], float, int] = "stable") -> float: - """Deprecated: Use read_weight() instead.""" - warnings.warn( - "get_weight() is deprecated and will be removed in 2026-03. Use read_weight() instead.", - DeprecationWarning, - stacklevel=2, - ) - return await self.read_weight(timeout) - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class MettlerToledoWXS205SDU: - def __init__(self, *args, **kwargs): - raise RuntimeError( - "`MettlerToledoWXS205SDU` is deprecated. Please use `MettlerToledoWXS205SDUBackend` instead." - ) diff --git a/pylabrobot/molecular_devices/__init__.py b/pylabrobot/molecular_devices/__init__.py new file mode 100644 index 00000000000..d21b01df731 --- /dev/null +++ b/pylabrobot/molecular_devices/__init__.py @@ -0,0 +1,9 @@ +from .imageXpress.pico.backend import PicoBackend +from .imageXpress.pico.pico import Pico +from .spectramax import ( + MolecularDevicesBackend, + SpectraMax384Plus, + SpectraMax384PlusBackend, + SpectraMaxM5, + SpectraMaxM5Backend, +) diff --git a/pylabrobot/molecular_devices/imageXpress/pico/__init__.py b/pylabrobot/molecular_devices/imageXpress/pico/__init__.py new file mode 100644 index 00000000000..34432c9de79 --- /dev/null +++ b/pylabrobot/molecular_devices/imageXpress/pico/__init__.py @@ -0,0 +1,2 @@ +from .backend import PicoBackend +from .pico import Pico diff --git a/pylabrobot/microscopes/molecular_devices/pico/backend.py b/pylabrobot/molecular_devices/imageXpress/pico/backend.py similarity index 86% rename from pylabrobot/microscopes/molecular_devices/pico/backend.py rename to pylabrobot/molecular_devices/imageXpress/pico/backend.py index 9b5cfacdc5b..eb86194d0fe 100644 --- a/pylabrobot/microscopes/molecular_devices/pico/backend.py +++ b/pylabrobot/molecular_devices/imageXpress/pico/backend.py @@ -9,6 +9,15 @@ from collections import defaultdict from typing import Callable, Dict, List, Optional, Tuple, TypeVar +from pylabrobot.capabilities.microscopy import ( + Exposure, + FocalPosition, + Gain, + ImagingMode, + ImagingResult, + MicroscopyBackend, + Objective, +) from pylabrobot.io.sila.grpc import ( command_execution_uuid, decode_command_confirmation, @@ -24,15 +33,6 @@ unlock_server_params, varint_as_signed, ) -from pylabrobot.plate_reading.backend import ImagerBackend -from pylabrobot.plate_reading.standard import ( - Exposure, - FocalPosition, - Gain, - ImagingMode, - ImagingResult, - Objective, -) from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import row_index_to_label from pylabrobot.resources.well import WellBottomType @@ -114,11 +114,7 @@ def _extract_integer(field_num: int) -> int: def _labware_params_from_plate(plate: Plate) -> dict: - """Derive Pico labware JSON from a PLR :class:`Plate`. - - Inspects well positions and geometry so the caller doesn't have to supply a - hand-crafted dict. - """ + """Derive Pico labware JSON from a PLR :class:`Plate`.""" well_a1 = plate.get_well("A1") nrows = plate.num_items_y ncols = plate.num_items_x @@ -131,8 +127,6 @@ def _labware_params_from_plate(plate: Plate) -> dict: well_size_x = well_a1.get_size_x() well_size_y = well_a1.get_size_y() - # PLR locations are Left-Front-Bottom of the well bounding box; Pico - # dist2first* are measured to the well center. dist2firstcol = a1_loc.x + well_size_x / 2.0 last_row_label = row_index_to_label(nrows - 1) @@ -310,7 +304,7 @@ def _buffer_to_ndarray(image_buffer: bytes, width: int, height: int): # Mapping from Objective enum to Pico objective ID strings _OBJECTIVE_MAP: Dict[Objective, str] = { - Objective.O_2_5X_N_PLAN: "N PLAN 2.5x/0.07", # Pico + Objective.O_2_5X_N_PLAN: "N PLAN 2.5x/0.07", Objective.O_4X_PL_FL: "PL FLUOTAR 4x/0.13", Objective.O_10X_PL_FL: "PL FLUOTAR 10x/0.30", Objective.O_20X_PL_FL: "PL FLUOTAR 20x/0.40", @@ -318,24 +312,17 @@ def _buffer_to_ndarray(image_buffer: bytes, width: int, height: int): } -class ExperimentalPicoBackend(ImagerBackend): +class PicoBackend(MicroscopyBackend): """Backend for Molecular Devices ImageXpress Pico automated microscope. - Communicates with the instrument via SiLA 2 over gRPC. All services (imaging, - door control, instrument control) run on a single port and share one lock. + Communicates with the instrument via SiLA 2 over gRPC. Args: host: IP address or hostname of the instrument. port: gRPC port (default 8091). - lock_timeout: Instrument lock timeout in seconds. The lock auto-releases if - no commands are sent within this period. + lock_timeout: Instrument lock timeout in seconds. objectives: Mapping from 0-indexed turret position to :class:`Objective`. - Applied during :meth:`setup` via :meth:`change_objective`. Not all - positions need to be specified. - filter_cubes: Mapping from 0-indexed filter wheel position to - :class:`ImagingMode`. The filter cube for that mode is installed at - the given position. Applied during :meth:`setup` via - :meth:`change_filter_cube`. Not all positions need to be specified. + filter_cubes: Mapping from 0-indexed filter wheel position to :class:`ImagingMode`. """ def __init__( @@ -383,7 +370,6 @@ def _lock_metadata(self) -> List[Tuple[str, bytes]]: return [(_LOCK_META_KEY, metadata_lock_identifier(self._lock_id))] async def _relock(self) -> None: - """Force-release any stale lock and re-acquire.""" try: await self._unlock() except (grpc.RpcError, RuntimeError): @@ -490,7 +476,6 @@ async def setup(self) -> None: ) self._lock_id = "pylabrobot" - # Try to unlock a stale lock from a previous session that didn't clean up. try: await self._unlock() except (grpc.RpcError, RuntimeError): @@ -532,16 +517,7 @@ async def stop(self) -> None: # -- configuration -- async def get_configuration(self) -> dict: - """Query the full instrument configuration (objectives, filter cubes, etc.). - - Returns the parsed InstrumentConfiguration JSON from the instrument. Key fields: - - ``objectivesComponent.objectives``: list of installed objectives, each with - ``Id``, ``Description``, ``PositionLabel``, ``Magnification``, ``NumericalAperture`` - - ``filterCubesComponent.filterCubes``: list of installed filter cubes, each with - ``Id``, ``Description``, ``PositionLabel`` - - ``excitationSources``: list of excitation sources with ``Id`` - """ - + """Query the full instrument configuration (objectives, filter cubes, etc.).""" raw = await self._call(_INST_SVC, "Get_InstrumentConfiguration", b"") data: dict = json.loads(decode_sila_string_response(raw)) return dict(data.get("InstrumentConfiguration", data)) @@ -550,19 +526,14 @@ async def get_configuration(self) -> dict: @property def door_open(self) -> bool: - """Whether the plate drawer is currently open (tracked client-side).""" return self._door_open async def open_door(self) -> None: - """Open the plate drawer.""" - await self._initialize() await self._call(_HW_SVC, "OpenPlateDrawer", b"", True) self._door_open = True async def close_door(self) -> None: - """Close the plate drawer.""" - await self._initialize() await self._call(_HW_SVC, "ClosePlateDrawer", b"", True) self._door_open = False @@ -570,12 +541,6 @@ async def close_door(self) -> None: # -- objective maintenance -- async def enter_objective_maintenance(self, position: int) -> None: - """Open the objective door for swapping objectives. - - Args: - position: 0-indexed objective turret position. - """ - if self._door_open: raise RuntimeError("Cannot enter objective maintenance while the plate drawer is open.") params = json.dumps({"Index": position}) @@ -584,21 +549,9 @@ async def enter_objective_maintenance(self, position: int) -> None: await self._call(_OBJ_SVC, "EnterObjectiveMaintenance", req, True) async def exit_objective_maintenance(self) -> None: - """Close the objective door after swapping objectives.""" - await self._call(_OBJ_SVC, "ExitObjectiveMaintenance", b"", True) async def get_available_objectives(self, position: int) -> List[dict]: - """Query which objectives are compatible with a given turret position. - - Args: - position: 0-indexed turret position. - - Returns: - List of objective dicts, each with ``Id``, ``Description``, ``Magnification``, - ``NumericalAperture``, ``PositionLabel``, ``IsCalibrated``, etc. - """ - params = json.dumps({"Index": position}) req = length_delimited(1, sila_string(params)) raw = await self._call(_OBJ_SVC, "GetAvailableObjectivesForPosition", req, True) @@ -606,33 +559,11 @@ async def get_available_objectives(self, position: int) -> List[dict]: return list(data.get("objectives", data.get("Objectives", []))) async def get_available_filter_cubes(self) -> List[dict]: - """Query which filter cubes are compatible with this instrument. - - Returns: - List of filter cube dicts, each with ``Id``, ``Description``, - ``PositionLabel``, ``IsCalibrated``, ``EmissionFilterPassBands``, - ``ExcitationFilterPassBands``, etc. - """ - raw = await self._call(_FC_SVC, "Get_CompatibleFilterCubes", b"") data: dict = json.loads(decode_sila_string_response(raw)) return list(data.get("filterCubes", data.get("FilterCubes", []))) async def change_objective(self, position: int, objective_id: str) -> None: - """Register a new objective in a turret position. - - Call this after physically swapping an objective (during maintenance mode) - to update the instrument's configuration. - - Args: - position: 0-indexed turret position. - objective_id: Objective ID string (e.g. ``"PL FLUOTAR 4x/0.13"``). - Use :meth:`get_available_objectives` to list valid IDs for a position. - - Raises: - ValueError: If ``objective_id`` is not compatible with the given position. - """ - available = await self.get_available_objectives(position) valid_ids = [obj.get("Id", obj.get("id")) for obj in available] if objective_id not in valid_ids: @@ -645,20 +576,6 @@ async def change_objective(self, position: int, objective_id: str) -> None: await self._call(_OBJ_SVC, "ChangeHardware", req, True) async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: - """Register a new filter cube in a filter wheel position. - - Call this after physically swapping a filter cube to update the - instrument's configuration. - - Args: - position: 0-indexed filter wheel position. - filter_cube_id: Filter cube ID string (e.g. ``"FITC"``). - Use :meth:`get_available_filter_cubes` to list valid IDs. - - Raises: - ValueError: If ``filter_cube_id`` is not a compatible filter cube. - """ - available = await self.get_available_filter_cubes() valid_ids = [fc.get("Id", fc.get("id")) for fc in available] if filter_cube_id not in valid_ids: @@ -673,13 +590,11 @@ async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: # -- imaging -- async def _snap_images(self, labware_params: dict, snap_params: dict) -> List[dict]: - """Acquire images via the SiLA 2 Observable Command flow.""" labware_json = json.dumps(labware_params) snap_json = json.dumps(snap_params) await self._initialize() - # Step 1: launch SnapImages command request = _snap_images_params(labware_json, snap_json) confirmation_raw = await self._call( _SNAP_SVC, "SnapImages", request, with_lock=True, timeout=60.0 @@ -687,7 +602,6 @@ async def _snap_images(self, labware_params: dict, snap_params: dict) -> List[di exec_uuid = decode_command_confirmation(confirmation_raw) logger.debug("SnapImages exec UUID: %s", exec_uuid[:8]) - # Step 2: stream intermediate responses (chunked image data) uuid_request = command_execution_uuid(exec_uuid) chunks: Dict[int, Dict[int, bytes]] = defaultdict(dict) checksums: Dict[int, int] = {} @@ -703,10 +617,8 @@ async def _snap_images(self, labware_params: dict, snap_params: dict) -> List[di chunks[meta["blob_index"]][meta["packet_index"]] = chunk_data checksums[meta["blob_index"]] = meta["blob_checksum"] - # Step 3: get result (signals command completion) await self._call(_SNAP_SVC, "SnapImages_Result", uuid_request, with_lock=True, timeout=60.0) - # Step 4: reassemble blobs and verify checksums images = [] for blob_idx in sorted(chunks.keys()): blob_chunks = chunks[blob_idx] diff --git a/pylabrobot/molecular_devices/imageXpress/pico/pico.py b/pylabrobot/molecular_devices/imageXpress/pico/pico.py new file mode 100644 index 00000000000..a88d8bf92cf --- /dev/null +++ b/pylabrobot/molecular_devices/imageXpress/pico/pico.py @@ -0,0 +1,61 @@ +from typing import Dict, Optional + +from pylabrobot.capabilities.microscopy import ImagingMode, MicroscopyCapability, Objective +from pylabrobot.device import Device +from pylabrobot.resources import Resource, Rotation + +from .backend import PicoBackend + + +class Pico(Resource, Device): + """Molecular Devices ImageXpress Pico automated microscope. + + Args: + name: Unique resource name. + host: IP address or hostname of the instrument. + port: gRPC port (default 8091). + lock_timeout: Instrument lock timeout in seconds. + objectives: Mapping from 0-indexed turret position to :class:`Objective`. + filter_cubes: Mapping from 0-indexed filter wheel position to :class:`ImagingMode`. + size_x: Instrument footprint X in mm. + size_y: Instrument footprint Y in mm. + size_z: Instrument footprint Z in mm. + """ + + def __init__( + self, + name: str, + host: str, + port: int = 8091, + lock_timeout: int = 3600, + objectives: Optional[Dict[int, Objective]] = None, + filter_cubes: Optional[Dict[int, ImagingMode]] = None, + size_x: float = 460.0, + size_y: float = 430.0, + size_z: float = 480.0, + rotation: Optional[Rotation] = None, + category: Optional[str] = "microscope", + model: Optional[str] = "ImageXpress Pico", + ): + backend = PicoBackend( + host=host, + port=port, + lock_timeout=lock_timeout, + objectives=objectives, + filter_cubes=filter_cubes, + ) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + ) + Device.__init__(self, backend=backend) + self._backend: PicoBackend = backend + + self.microscopy = MicroscopyCapability(backend=backend) + self._capabilities = [self.microscopy] diff --git a/pylabrobot/molecular_devices/spectramax/__init__.py b/pylabrobot/molecular_devices/spectramax/__init__.py new file mode 100644 index 00000000000..dccea07989c --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/__init__.py @@ -0,0 +1,23 @@ +from .backend import ( + COMMAND_TERMINATORS, + ERROR_CODES, + Calibrate, + CarriageSpeed, + KineticSettings, + MolecularDevicesBackend, + MolecularDevicesError, + MolecularDevicesFirmwareError, + MolecularDevicesHardwareError, + MolecularDevicesMotionError, + MolecularDevicesNVRAMError, + MolecularDevicesSettings, + MolecularDevicesUnrecognizedCommandError, + PmtGain, + ReadMode, + ReadOrder, + ReadType, + ShakeSettings, + SpectrumSettings, +) +from .spectramax_384_plus import SpectraMax384Plus, SpectraMax384PlusBackend +from .spectramax_m5 import SpectraMaxM5, SpectraMaxM5Backend diff --git a/pylabrobot/plate_reading/molecular_devices/backend.py b/pylabrobot/molecular_devices/spectramax/backend.py similarity index 72% rename from pylabrobot/plate_reading/molecular_devices/backend.py rename to pylabrobot/molecular_devices/spectramax/backend.py index fa573388958..870502c3025 100644 --- a/pylabrobot/plate_reading/molecular_devices/backend.py +++ b/pylabrobot/molecular_devices/spectramax/backend.py @@ -2,14 +2,16 @@ import logging import re import time -from abc import ABCMeta from dataclasses import dataclass, field from enum import Enum from typing import Dict, List, Literal, Optional, Tuple, Union +from pylabrobot.capabilities.plate_reading.absorbance.backend import AbsorbanceBackend +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend from pylabrobot.io.serial import Serial -from pylabrobot.plate_reading.backend import PlateReaderBackend from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well logger = logging.getLogger("pylabrobot") @@ -246,13 +248,21 @@ class MolecularDevicesSettings: settling_time: int = 0 -class MolecularDevicesBackend(PlateReaderBackend, metaclass=ABCMeta): - """Backend for Molecular Devices plate readers.""" +class MolecularDevicesBackend(AbsorbanceBackend, TemperatureControllerBackend): + """Backend for Molecular Devices plate readers. Supports absorbance reading. - def __init__(self, port: str) -> None: + Contains all serial protocol code, enums, dataclasses, and exceptions shared + across Molecular Devices SpectraMax instruments. Subclasses add fluorescence, + luminescence, and other capabilities. + """ + + def __init__( + self, port: str, human_readable_device_name: str = "Molecular Devices Plate Reader" + ) -> None: + super().__init__() self.port = port self.io = Serial( - human_readable_device_name="Molecular Devices Plate Reader", + human_readable_device_name=human_readable_device_name, port=self.port, baudrate=9600, timeout=0.2, @@ -355,11 +365,22 @@ async def get_temperature(self) -> Tuple[float, float]: return (float(parts[1]), float(parts[0])) # current, set_point raise ValueError(f"Could not parse temperature from response: {res}") + @property + def supports_active_cooling(self) -> bool: + return False + + async def get_current_temperature(self) -> float: + current, _ = await self.get_temperature() + return current + async def set_temperature(self, temperature: float) -> None: if not (0 <= temperature <= 45): raise ValueError("Temperature must be between 0 and 45°C.") await self.send_command(f"!TEMP {temperature}") + async def deactivate(self) -> None: + await self.send_command("!TEMP 0") + async def get_firmware_version(self) -> List[str]: res = await self.send_command("!OPTION") return res[1].split() @@ -374,8 +395,8 @@ async def _read_now(self) -> None: await self.send_command("!READ") async def _transfer_data(self, settings: MolecularDevicesSettings) -> List[Dict]: - """Transfer data from the plate reader. For kinetic/spectrum reads, this will transfer data for each - reading and combine them into a single collection. + """Transfer data from the plate reader. For kinetic/spectrum reads, this will transfer data + for each reading and combine them into a single collection. """ if (settings.read_type == ReadType.KINETIC and settings.kinetic_settings) or ( @@ -393,7 +414,7 @@ async def _transfer_data(self, settings: MolecularDevicesSettings) -> List[Dict] res = await self.send_command("!TRANSFER") data_str = res[1] read_data = self._parse_data(data_str, settings) - all_reads.extend(read_data) # Unpack the list + all_reads.extend(read_data) return all_reads # For ENDPOINT @@ -470,7 +491,7 @@ def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> List if read_mode == ReadMode.ABS: wl = int(cur_read_wavelengths[i][0]) measurement["wavelength"] = wl - elif read_mode == ReadMode.FLU or read_mode == ReadMode.POLAR or read_mode == ReadMode.TIME: + elif read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME): ex_wl = int(cur_read_wavelengths[i][0]) em_wl = int(cur_read_wavelengths[i][1]) measurement["ex_wavelength"] = ex_wl @@ -515,7 +536,7 @@ async def _set_wavelengths(self, settings: MolecularDevicesSettings) -> None: wl_str = " ".join(map(str, settings.emission_wavelengths)) await self.send_command(f"!EMWAVELENGTH {wl_str}") else: - raise NotImplementedError("f{settings.read_mode} not supported") + raise NotImplementedError(f"{settings.read_mode} not supported") async def _set_plate_position(self, settings: MolecularDevicesSettings) -> None: plate = settings.plate @@ -655,8 +676,6 @@ async def _set_integration_time( def _get_cutoff_filter_index_from_wavelength(self, wavelength: int) -> int: """Converts a wavelength to a cutoff filter index.""" - # This map is a direct translation of the `EmissionCutoff.CutoffFilter` in MaxlineModel.cs - # (min_wavelength, max_wavelength, cutoff_filter_index) FILTERS = [ (0, 322, 1), (325, 415, 16), @@ -691,10 +710,13 @@ async def _wait_for_idle(self, timeout: int = 600): break await asyncio.sleep(1) - async def read_absorbance( # type: ignore[override] + async def read_absorbance( self, plate: Plate, - wavelengths: List[Union[int, Tuple[int, bool]]], + wells: List[Well], + wavelength: int, + *, + wavelengths: Optional[List[Union[int, Tuple[int, bool]]]] = None, read_type: ReadType = ReadType.ENDPOINT, read_order: ReadOrder = ReadOrder.COLUMN, calibrate: Calibrate = Calibrate.ONCE, @@ -707,7 +729,9 @@ async def read_absorbance( # type: ignore[override] cuvette: bool = False, settling_time: int = 0, timeout: int = 600, - ) -> List[Dict]: + ) -> List[AbsorbanceResult]: + if wavelengths is None: + wavelengths = [wavelength] settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.ABS, @@ -742,257 +766,13 @@ async def read_absorbance( # type: ignore[override] await self._read_now() await self._wait_for_idle(timeout=timeout) - return await self._transfer_data(settings) - - async def read_fluorescence( # type: ignore[override] - self, - plate: Plate, - excitation_wavelengths: List[int], - emission_wavelengths: List[int], - cutoff_filters: List[int], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 10, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False, - settling_time: int = 0, - timeout: int = 600, - ) -> List[Dict]: - """use _get_cutoff_filter_index_from_wavelength for cutoff_filters""" - settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.FLU, - read_type=read_type, - read_order=read_order, - calibrate=calibrate, - shake_settings=shake_settings, - carriage_speed=carriage_speed, - read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, - flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, - spectrum_settings=spectrum_settings, - excitation_wavelengths=excitation_wavelengths, - emission_wavelengths=emission_wavelengths, - cutoff_filters=cutoff_filters, - cuvette=cuvette, - speed_read=False, - settling_time=settling_time, - ) - await self._set_clear() - if not cuvette: - await self._set_plate_position(settings) - await self._set_strip(settings) - await self._set_carriage_speed(settings) - - await self._set_shake(settings) - await self._set_flashes_per_well(settings) - await self._set_pmt(settings) - await self._set_wavelengths(settings) - await self._set_filter(settings) - await self._set_read_stage(settings) - await self._set_calibrate(settings) - await self._set_mode(settings) - await self._set_order(settings) - await self._set_tag(settings) - await self._set_nvram(settings) - await self._set_readtype(settings) - - await self._read_now() - await self._wait_for_idle(timeout=timeout) - return await self._transfer_data(settings) - - async def read_luminescence( # type: ignore[override] - self, - plate: Plate, - emission_wavelengths: List[int], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 0, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False, - settling_time: int = 0, - timeout: int = 600, - ) -> List[Dict]: - settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.LUM, - read_type=read_type, - read_order=read_order, - calibrate=calibrate, - shake_settings=shake_settings, - carriage_speed=carriage_speed, - read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, - flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, - spectrum_settings=spectrum_settings, - emission_wavelengths=emission_wavelengths, - cuvette=cuvette, - speed_read=False, - settling_time=settling_time, - ) - await self._set_clear() - await self._set_read_stage(settings) - - if not cuvette: - await self._set_plate_position(settings) - await self._set_strip(settings) - await self._set_carriage_speed(settings) - - await self._set_shake(settings) - await self._set_pmt(settings) - await self._set_wavelengths(settings) - await self._set_read_stage(settings) - await self._set_calibrate(settings) - await self._set_mode(settings) - await self._set_order(settings) - await self._set_tag(settings) - await self._set_nvram(settings) - await self._set_readtype(settings) - - await self._read_now() - await self._wait_for_idle(timeout=timeout) - return await self._transfer_data(settings) - - async def read_fluorescence_polarization( - self, - plate: Plate, - excitation_wavelengths: List[int], - emission_wavelengths: List[int], - cutoff_filters: List[int], - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 10, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False, - settling_time: int = 0, - timeout: int = 600, - ) -> List[Dict]: - settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.POLAR, - read_type=read_type, - read_order=read_order, - calibrate=calibrate, - shake_settings=shake_settings, - carriage_speed=carriage_speed, - read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, - flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, - spectrum_settings=spectrum_settings, - excitation_wavelengths=excitation_wavelengths, - emission_wavelengths=emission_wavelengths, - cutoff_filters=cutoff_filters, - cuvette=cuvette, - speed_read=False, - settling_time=settling_time, - ) - await self._set_clear() - if not cuvette: - await self._set_plate_position(settings) - await self._set_strip(settings) - await self._set_carriage_speed(settings) - - await self._set_shake(settings) - await self._set_flashes_per_well(settings) - await self._set_pmt(settings) - await self._set_wavelengths(settings) - await self._set_filter(settings) - await self._set_read_stage(settings) - await self._set_calibrate(settings) - await self._set_mode(settings) - await self._set_order(settings) - await self._set_tag(settings) - await self._set_nvram(settings) - await self._set_readtype(settings) - - await self._read_now() - await self._wait_for_idle(timeout=timeout) - return await self._transfer_data(settings) - - async def read_time_resolved_fluorescence( - self, - plate: Plate, - excitation_wavelengths: List[int], - emission_wavelengths: List[int], - cutoff_filters: List[int], - delay_time: int, - integration_time: int, - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 50, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False, - settling_time: int = 0, - timeout: int = 600, - ) -> List[Dict]: - settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.TIME, - read_type=read_type, - read_order=read_order, - calibrate=calibrate, - shake_settings=shake_settings, - carriage_speed=carriage_speed, - read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, - flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, - spectrum_settings=spectrum_settings, - excitation_wavelengths=excitation_wavelengths, - emission_wavelengths=emission_wavelengths, - cutoff_filters=cutoff_filters, - cuvette=cuvette, - speed_read=False, - settling_time=settling_time, - ) - await self._set_clear() - await self._set_readtype(settings) - await self._set_integration_time(settings, delay_time, integration_time) - - if not cuvette: - await self._set_plate_position(settings) - await self._set_strip(settings) - await self._set_carriage_speed(settings) - - await self._set_shake(settings) - await self._set_flashes_per_well(settings) - await self._set_pmt(settings) - await self._set_wavelengths(settings) - await self._set_filter(settings) - await self._set_calibrate(settings) - await self._set_read_stage(settings) - await self._set_mode(settings) - await self._set_order(settings) - await self._set_tag(settings) - await self._set_nvram(settings) - - await self._read_now() - await self._wait_for_idle(timeout=timeout) - return await self._transfer_data(settings) + dicts = await self._transfer_data(settings) + return [ + AbsorbanceResult( + data=d["data"], + wavelength=d["wavelength"], + temperature=d["temperature"], + timestamp=d["time"], + ) + for d in dicts + ] diff --git a/pylabrobot/plate_reading/molecular_devices/backend_tests.py b/pylabrobot/molecular_devices/spectramax/backend_tests.py similarity index 87% rename from pylabrobot/plate_reading/molecular_devices/backend_tests.py rename to pylabrobot/molecular_devices/spectramax/backend_tests.py index 589d65386cb..5f13a24882b 100644 --- a/pylabrobot/plate_reading/molecular_devices/backend_tests.py +++ b/pylabrobot/molecular_devices/spectramax/backend_tests.py @@ -2,7 +2,10 @@ import unittest from unittest.mock import AsyncMock, MagicMock, call, patch -from pylabrobot.plate_reading.molecular_devices.backend import ( +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.molecular_devices.spectramax.backend import ( Calibrate, CarriageSpeed, KineticSettings, @@ -17,6 +20,7 @@ ShakeSettings, SpectrumSettings, ) +from pylabrobot.molecular_devices.spectramax.spectramax_m5 import SpectraMaxM5Backend from pylabrobot.resources.agenbio.plates import AGenBio_96_wellplate_Ub_2200ul @@ -41,7 +45,6 @@ def setUp(self): self.addCleanup(patch.stopall) async def test_setup_stop(self): - # un-mock send_command for this test with patch.object( self.backend, "send_command", wraps=self.backend.send_command ) as wrapped_send_command: @@ -454,21 +457,29 @@ async def test_set_tag(self): self.send_command_mock.assert_called_once_with("!TAG OFF") @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, - return_value="", + return_value=[{"data": [[0.1]], "wavelength": 500, "temperature": 25.0, "time": 12345.6}], ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) async def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - await self.backend.read_absorbance(plate, [500]) + results = await self.backend.read_absorbance(plate, plate.get_wells(), 500) + + # Verify typed results + self.assertIsInstance(results, list) + self.assertEqual(len(results), 1) + self.assertIsInstance(results[0], AbsorbanceResult) + self.assertEqual(results[0].wavelength, 500) + self.assertEqual(results[0].temperature, 25.0) + self.assertEqual(results[0].timestamp, 12345.6) commands = [c.args[0] for c in self.send_command_mock.call_args_list] self.assertIn("!CLEAR DATA", commands) @@ -490,22 +501,62 @@ async def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wai mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() + +class TestSpectraMaxM5Backend(unittest.IsolatedAsyncioTestCase): + backend: SpectraMaxM5Backend + mock_serial: MagicMock + send_command_mock: AsyncMock + + def setUp(self): + self.mock_serial = MagicMock() + self.mock_serial.setup = AsyncMock() + self.mock_serial.stop = AsyncMock() + self.mock_serial.write = AsyncMock() + self.mock_serial.readline = AsyncMock(return_value=b"OK>\r\n") + + with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): + self.backend = SpectraMaxM5Backend(port="COM1") + self.backend.io = self.mock_serial + self.send_command_mock = patch.object( + self.backend, "send_command", new_callable=AsyncMock + ).start() + self.addCleanup(patch.stopall) + @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, - return_value="", + return_value=[ + { + "data": [[100.0]], + "ex_wavelength": 485, + "em_wavelength": 520, + "temperature": 25.0, + "time": 12345.6, + } + ], ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - await self.backend.read_fluorescence(plate, [485], [520], [515]) + results = await self.backend.read_fluorescence( + plate, plate.get_wells(), excitation_wavelength=485, emission_wavelength=520, focal_height=0 + ) + + # Verify typed results + self.assertIsInstance(results, list) + self.assertEqual(len(results), 1) + self.assertIsInstance(results[0], FluorescenceResult) + self.assertEqual(results[0].excitation_wavelength, 485) + self.assertEqual(results[0].emission_wavelength, 520) + self.assertEqual(results[0].temperature, 25.0) + self.assertEqual(results[0].timestamp, 12345.6) commands = [c.args[0] for c in self.send_command_mock.call_args_list] self.assertIn("!CLEAR DATA", commands) @@ -518,8 +569,6 @@ async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_w self.assertIn("!AUTOPMT ON", commands) self.assertIn("!EXWAVELENGTH 485", commands) self.assertIn("!EMWAVELENGTH 520", commands) - self.assertIn("!AUTOFILTER OFF", commands) - self.assertIn("!EMFILTER 515", commands) self.assertIn("!PMTCAL ONCE", commands) self.assertIn("!MODE ENDPOINT", commands) self.assertIn("!ORDER COLUMN", commands) @@ -535,21 +584,30 @@ async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_w mock_transfer_data.assert_called_once() @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, - return_value="", + return_value=[{"data": [[1000.0]], "em_wavelength": 590, "temperature": 25.0, "time": 12345.6}], ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - await self.backend.read_luminescence(plate, [590]) + results = await self.backend.read_luminescence( + plate, plate.get_wells(), focal_height=0, emission_wavelengths=[590] + ) + + # Verify typed results + self.assertIsInstance(results, list) + self.assertEqual(len(results), 1) + self.assertIsInstance(results[0], LuminescenceResult) + self.assertEqual(results[0].temperature, 25.0) + self.assertEqual(results[0].timestamp, 12345.6) commands = [c.args[0] for c in self.send_command_mock.call_args_list] self.assertIn("!CLEAR DATA", commands) @@ -573,17 +631,22 @@ async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_w mock_wait_for_idle.assert_called_once() mock_transfer_data.assert_called_once() + async def test_read_luminescence_requires_emission_wavelengths(self): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + with self.assertRaises(ValueError): + await self.backend.read_luminescence(plate, plate.get_wells(), focal_height=0) + @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) async def test_read_fluorescence_polarization( @@ -623,16 +686,16 @@ async def test_read_fluorescence_polarization( mock_transfer_data.assert_called_once() @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._read_now", new_callable=AsyncMock, ) async def test_read_time_resolved_fluorescence( @@ -835,7 +898,6 @@ def test_parse_data_with_sat_and_nan(self): self.assertTrue(math.isnan(read["data"][1][1])) async def test_parse_kinetic_absorbance(self): - # Mock the send_command to return two different data blocks def data_generator(): yield [ "OK", @@ -883,7 +945,6 @@ def data_generator(): self.assertEqual(result[1]["time"], 12355.6) async def test_parse_spectrum_absorbance(self): - # Mock the send_command to return two different data blocks for two wavelengths def data_generator(): yield [ "OK", @@ -950,7 +1011,6 @@ async def _mock_send_command_response(self, response_str: str): return await self.backend.send_command("!TEST") async def test_parse_basic_errors_fail_known_error_code(self): - # Test a known error code (e.g., 107: no data to transfer) with self.assertRaisesRegex( MolecularDevicesUnrecognizedCommandError, "Command '!TEST' failed with error 107: no data to transfer", @@ -958,37 +1018,30 @@ async def test_parse_basic_errors_fail_known_error_code(self): await self._mock_send_command_response("OK\t\r\n>FAIL\t 107") async def test_parse_basic_errors_fail_unknown_error_code(self): - # Test an unknown error code with self.assertRaisesRegex( MolecularDevicesError, "Command '!TEST' failed with unknown error code: 999" ): await self._mock_send_command_response("FAIL\t 999") async def test_parse_basic_errors_fail_unparsable_error(self): - # Test an unparsable error message (e.g., not an integer code) with self.assertRaisesRegex( MolecularDevicesError, "Command '!TEST' failed with unparsable error: FAIL\t ABC" ): await self._mock_send_command_response("FAIL\t ABC") async def test_parse_basic_errors_empty_response(self): - # Test an empty response from the device - self.mock_serial.readline.return_value = b"" # Simulate no response + self.mock_serial.readline.return_value = b"" with self.assertRaisesRegex(TimeoutError, "Timeout waiting for response to command: !TEST"): - await self.backend.send_command("!TEST", timeout=1) # Short timeout for test + await self.backend.send_command("!TEST", timeout=1) async def test_parse_basic_errors_warning_response(self): - # Test a response containing a warning self.mock_serial.readline.side_effect = [b"OK\tWarning: Something happened>\r\n"] - # Expect no exception, but a warning logged (not directly testable with assertRaises) - # We can assert that no error is raised. try: await self.backend.send_command("!TEST") except MolecularDevicesError: self.fail("MolecularDevicesError raised for a warning response") async def test_parse_basic_errors_ok_response(self): - # Test a normal OK response self.mock_serial.readline.side_effect = [b"OK>\r\n"] try: response = await self.backend.send_command("!TEST") diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py b/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py new file mode 100644 index 00000000000..0c8563a520b --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py @@ -0,0 +1,78 @@ +from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability +from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, PlateHolder, Resource + +from .backend import MolecularDevicesBackend, MolecularDevicesSettings + + +class SpectraMax384PlusBackend(MolecularDevicesBackend): + """Backend for Molecular Devices SpectraMax 384 Plus plate readers. + + Absorbance only. Overrides ``_set_readtype`` (simpler CUV/PLA), and no-ops + ``_set_nvram`` / ``_set_tag``. + """ + + def __init__(self, port: str) -> None: + super().__init__(port, human_readable_device_name="Molecular Devices SpectraMax 384 Plus") + + async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: + cmd = f"!READTYPE {'CUV' if settings.cuvette else 'PLA'}" + await self.send_command(cmd, num_res_fields=1) + + async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: + pass + + async def _set_tag(self, settings: MolecularDevicesSettings) -> None: + pass + + +# --------------------------------------------------------------------------- +# Device +# --------------------------------------------------------------------------- + + +class SpectraMax384Plus(Resource, Device): + """Molecular Devices SpectraMax 384 Plus plate reader. Absorbance only.""" + + def __init__( + self, + name: str, + port: str, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + backend = SpectraMax384PlusBackend(port=port) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Molecular Devices SpectraMax 384 Plus", + ) + Device.__init__(self, backend=backend) + self._backend: SpectraMax384PlusBackend = backend + self.absorbance = AbsorbanceCapability(backend=backend) + self.tc = TemperatureControlCapability(backend=backend) + self._capabilities = [self.absorbance, self.tc] + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, + size_y=85.48, + size_z=0, # TODO: measure + pedestal_size_z=0, # TODO: measure + child_location=Coordinate.zero(), # TODO: measure + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + async def open(self) -> None: + await self._backend.open() + + async def close(self) -> None: + await self._backend.close() diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py new file mode 100644 index 00000000000..7f452bec9f2 --- /dev/null +++ b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py @@ -0,0 +1,385 @@ +from typing import Dict, List, Optional, Union + +from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability +from pylabrobot.capabilities.plate_reading.fluorescence import FluorescenceCapability +from pylabrobot.capabilities.plate_reading.fluorescence.backend import FluorescenceBackend +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability +from pylabrobot.capabilities.plate_reading.luminescence.backend import LuminescenceBackend +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, PlateHolder, Resource +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well + +from .backend import ( + Calibrate, + CarriageSpeed, + KineticSettings, + MolecularDevicesBackend, + MolecularDevicesSettings, + PmtGain, + ReadMode, + ReadOrder, + ReadType, + ShakeSettings, + SpectrumSettings, +) + + +class SpectraMaxM5Backend(MolecularDevicesBackend, FluorescenceBackend, LuminescenceBackend): + """Backend for Molecular Devices SpectraMax M5 plate readers. + + Supports absorbance (inherited), fluorescence, luminescence, fluorescence polarization, + and time-resolved fluorescence. + """ + + def __init__(self, port: str) -> None: + super().__init__(port, human_readable_device_name="Molecular Devices SpectraMax M5") + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + *, + excitation_wavelengths: Optional[List[int]] = None, + emission_wavelengths: Optional[List[int]] = None, + cutoff_filters: Optional[List[int]] = None, + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[FluorescenceResult]: + if excitation_wavelengths is None: + excitation_wavelengths = [excitation_wavelength] + if emission_wavelengths is None: + emission_wavelengths = [emission_wavelength] + if cutoff_filters is None: + cutoff_filters = [self._get_cutoff_filter_index_from_wavelength(emission_wavelength)] + + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.FLU, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + ) + await self._set_clear() + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_flashes_per_well(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_filter(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) + + await self._read_now() + await self._wait_for_idle(timeout=timeout) + dicts = await self._transfer_data(settings) + return [ + FluorescenceResult( + data=d["data"], + excitation_wavelength=d["ex_wavelength"], + emission_wavelength=d["em_wavelength"], + temperature=d["temperature"], + timestamp=d["time"], + ) + for d in dicts + ] + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + *, + emission_wavelengths: Optional[List[int]] = None, + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 0, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[LuminescenceResult]: + if emission_wavelengths is None: + raise ValueError("emission_wavelengths is required for SpectraMax M5 luminescence reads") + + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.LUM, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + emission_wavelengths=emission_wavelengths, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + ) + await self._set_clear() + await self._set_read_stage(settings) + + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) + + await self._read_now() + await self._wait_for_idle(timeout=timeout) + dicts = await self._transfer_data(settings) + return [ + LuminescenceResult( + data=d["data"], + temperature=d["temperature"], + timestamp=d["time"], + ) + for d in dicts + ] + + async def read_fluorescence_polarization( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 10, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.POLAR, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + ) + await self._set_clear() + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_flashes_per_well(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_filter(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) + + await self._read_now() + await self._wait_for_idle(timeout=timeout) + return await self._transfer_data(settings) + + async def read_time_resolved_fluorescence( + self, + plate: Plate, + excitation_wavelengths: List[int], + emission_wavelengths: List[int], + cutoff_filters: List[int], + delay_time: int, + integration_time: int, + read_type: ReadType = ReadType.ENDPOINT, + read_order: ReadOrder = ReadOrder.COLUMN, + calibrate: Calibrate = Calibrate.ONCE, + shake_settings: Optional[ShakeSettings] = None, + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, + read_from_bottom: bool = False, + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, + flashes_per_well: int = 50, + kinetic_settings: Optional[KineticSettings] = None, + spectrum_settings: Optional[SpectrumSettings] = None, + cuvette: bool = False, + settling_time: int = 0, + timeout: int = 600, + ) -> List[Dict]: + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.TIME, + read_type=read_type, + read_order=read_order, + calibrate=calibrate, + shake_settings=shake_settings, + carriage_speed=carriage_speed, + read_from_bottom=read_from_bottom, + pmt_gain=pmt_gain, + flashes_per_well=flashes_per_well, + kinetic_settings=kinetic_settings, + spectrum_settings=spectrum_settings, + excitation_wavelengths=excitation_wavelengths, + emission_wavelengths=emission_wavelengths, + cutoff_filters=cutoff_filters, + cuvette=cuvette, + speed_read=False, + settling_time=settling_time, + ) + await self._set_clear() + await self._set_readtype(settings) + await self._set_integration_time(settings, delay_time, integration_time) + + if not cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_flashes_per_well(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_filter(settings) + await self._set_calibrate(settings) + await self._set_read_stage(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + + await self._read_now() + await self._wait_for_idle(timeout=timeout) + return await self._transfer_data(settings) + + +# --------------------------------------------------------------------------- +# Device +# --------------------------------------------------------------------------- + + +class SpectraMaxM5(Resource, Device): + """Molecular Devices SpectraMax M5 plate reader. + + Supports absorbance, fluorescence, and luminescence capabilities. + Also supports fluorescence polarization and time-resolved fluorescence + via direct backend access. + """ + + def __init__( + self, + name: str, + port: str, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + backend = SpectraMaxM5Backend(port=port) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Molecular Devices SpectraMax M5", + ) + Device.__init__(self, backend=backend) + self._backend: SpectraMaxM5Backend = backend + self.absorbance = AbsorbanceCapability(backend=backend) + self.luminescence = LuminescenceCapability(backend=backend) + self.fluorescence = FluorescenceCapability(backend=backend) + self.tc = TemperatureControlCapability(backend=backend) + self._capabilities = [self.absorbance, self.luminescence, self.fluorescence, self.tc] + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, + size_y=85.48, + size_z=0, # TODO: measure + pedestal_size_z=0, # TODO: measure + child_location=Coordinate.zero(), # TODO: measure + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + async def open(self) -> None: + await self._backend.open() + + async def close(self) -> None: + await self._backend.close() diff --git a/pylabrobot/only_fans/hamilton_hepa_fan_backend.py b/pylabrobot/only_fans/hamilton_hepa_fan_backend.py deleted file mode 100644 index 0d62c6143f2..00000000000 --- a/pylabrobot/only_fans/hamilton_hepa_fan_backend.py +++ /dev/null @@ -1,162 +0,0 @@ -import asyncio - -from pylabrobot.io.ftdi import FTDI - -from .backend import FanBackend - - -class HamiltonHepaFanBackend(FanBackend): - """Backend for Hepa fan attachment on Hamilton Liquid Handler""" - - def __init__(self, device_id=None): - self.io = FTDI( - human_readable_device_name="Hamilton HEPA Fan", device_id=device_id, vid=0x0856, pid=0xAC11 - ) - - async def setup(self): - await self.io.setup() - await self.io.set_baudrate(9600) - await self.io.set_line_property(8, 0, 0) # 8N1 - await self.io.set_latency_timer(16) - await self.io.set_flowctrl(512) - await self.io.set_dtr(True) - await self.io.set_rts(True) - - await self.send(b"\x55\xc1\x01\x02\x23\x4b") - await self.send(b"\x55\xc1\x01\x08\x08\x6a") - await self.send(b"\x55\xc1\x01\x09\x6a\x09") - await self.send(b"\x55\xc1\x01\x0a\x2f\x4f") - await self.send(b"\x15\x61\x01\x8a") - - async def turn_on(self, intensity): # Speed is an integer percent between 0 and 100 - if int(intensity) != intensity or not 0 <= intensity <= 100: - raise ValueError("Intensity is not an int value between 0 and 100") - await self.send(b"\x35\x41\x01\xff\x75") # turn on - - speed_array = [ - "55c10111007b", - "55c101110279", - "55c10111057e", - "55c10111077c", - "55c101110a71", - "55c101110c77", - "55c101110f74", - "55c10111116a", - "55c10111146f", - "55c10111166d", - "55c101111962", - "55c101111c67", - "55c101111e65", - "55c10111215a", - "55c101112358", - "55c10111265d", - "55c101112853", - "55c101112b50", - "55c101112d56", - "55c10111304b", - "55c101113249", - "55c10111354e", - "55c101113843", - "55c101113a41", - "55c101113d46", - "55c101113f44", - "55c101114239", - "55c10111443f", - "55c10111473c", - "55c101114932", - "55c101114c37", - "55c101114f34", - "55c10111512a", - "55c10111542f", - "55c10111562d", - "55c101115922", - "55c101115b20", - "55c101115e25", - "55c10111601b", - "55c101116318", - "55c10111651e", - "55c101116813", - "55c101116b10", - "55c101116d16", - "55c10111700b", - "55c101117209", - "55c10111750e", - "55c10111770c", - "55c101117a01", - "55c101117c07", - "55c101117f04", - "55c1011182f9", - "55c1011184ff", - "55c1011187fc", - "55c1011189f2", - "55c101118cf7", - "55c101118ef5", - "55c1011191ea", - "55c1011193e8", - "55c1011196ed", - "55c1011198e3", - "55c101119be0", - "55c101119ee5", - "55c10111a0db", - "55c10111a3d8", - "55c10111a5de", - "55c10111a8d3", - "55c10111aad1", - "55c10111add6", - "55c10111afd4", - "55c10111b2c9", - "55c10111b5ce", - "55c10111b7cc", - "55c10111bac1", - "55c10111bcc7", - "55c10111bfc4", - "55c10111c1ba", - "55c10111c4bf", - "55c10111c6bd", - "55c10111c9b2", - "55c10111cbb0", - "55c10111ceb5", - "55c10111d1aa", - "55c10111d3a8", - "55c10111d6ad", - "55c10111d8a3", - "55c10111dba0", - "55c10111dda6", - "55c10111e09b", - "55c10111e299", - "55c10111e59e", - "55c10111e893", - "55c10111ea91", - "55c10111ed96", - "55c10111ef94", - "55c10111f289", - "55c10111f48f", - "55c10111f78c", - "55c10111f982", - "55c10111fc87", - "55c10111fe85", - ] - - await self.send(bytes.fromhex(speed_array[intensity])) # set speed - - async def turn_off(self): - await self.send(b"\x55\xc1\x01\x11\x00\x7b") - - async def stop(self): - await self.io.stop() - - async def send(self, command: bytes): - await self.io.write(command) - await asyncio.sleep(0.1) - await self.io.read(64) - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class HamiltonHepaFan: - def __init__(self, *args, **kwargs): - raise RuntimeError( - "`HamiltonHepaFan` is deprecated. Please use `HamiltonHepaFanBackend` instead." - ) diff --git a/pylabrobot/peeling/xpeel.py b/pylabrobot/peeling/xpeel.py deleted file mode 100644 index 7a0d2f7127a..00000000000 --- a/pylabrobot/peeling/xpeel.py +++ /dev/null @@ -1,8 +0,0 @@ -from pylabrobot.peeling.peeler import Peeler -from pylabrobot.peeling.xpeel_backend import XPeelBackend - - -def xpeel(port: str) -> Peeler: - return Peeler( - backend=XPeelBackend(port=port), - ) diff --git a/pylabrobot/plate_reading/agilent/__init__.py b/pylabrobot/plate_reading/agilent/__init__.py deleted file mode 100644 index 59e960a561c..00000000000 --- a/pylabrobot/plate_reading/agilent/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .biotek_backend import BioTekPlateReaderBackend -from .biotek_cytation_backend import ( - Cytation5Backend, - Cytation5ImagingConfig, - CytationBackend, - CytationImagingConfig, -) -from .biotek_synergyh1_backend import SynergyH1Backend diff --git a/pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py b/pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py deleted file mode 100644 index 5036bb33b83..00000000000 --- a/pylabrobot/plate_reading/agilent/biotek_synergyh1_backend.py +++ /dev/null @@ -1,88 +0,0 @@ -import asyncio -import logging -import time -from typing import Optional - -try: - from pylibftdi import FtdiError - - HAS_PYLIBFTDI = True -except ImportError: - HAS_PYLIBFTDI = False - FtdiError = Exception # type: ignore[misc,assignment] - -from pylabrobot.plate_reading.agilent.biotek_backend import BioTekPlateReaderBackend - -logger = logging.getLogger(__name__) - - -class SynergyH1Backend(BioTekPlateReaderBackend): - """Backend for Agilent BioTek Synergy H1 plate readers.""" - - @property - def supports_heating(self): - return True - - @property - def supports_cooling(self): - return False - - @property - def focal_height_range(self): - return (4.5, 10.68) - - async def _read_until( - self, terminator: bytes, timeout: Optional[float] = None, chunk_size: int = 512 - ) -> bytes: - if timeout is None: - timeout = self.timeout - - deadline = time.time() + timeout - buf = bytearray() - - retries = 0 - max_retries = 3 - - while True: - if time.time() > deadline: - logger.debug( - f"{self.__class__.__name__} _read_until timed out; partial buffer (hex): %s", buf.hex() - ) - raise TimeoutError( - f"{self.__class__.__name__} _read_until timed out waiting for {terminator!r}; partial={buf.hex()}" - ) - - try: - data = await self.io.read(chunk_size) - if len(data) == 0: - await asyncio.sleep(0.02) - continue - - buf.extend(data) - - if terminator in buf: - idx = buf.index(terminator) + len(terminator) - full = bytes(buf[:idx]) - logger.debug( - f"{self.__class__.__name__} _read_until received %d bytes (hex prefix): %s", - len(full), - full[:200].hex(), - ) - return full - - except FtdiError as e: - retries += 1 - logger.warning( - f"{self.__class__.__name__} transient FtdiError while reading: %s — retrying", e - ) - - if retries >= max_retries: - logger.warning( - f"{self.__class__.__name__} too many FtdiError retries ({max_retries}) — stopping", e - ) - raise - - await asyncio.sleep(0.05) - continue - except Exception: - raise diff --git a/pylabrobot/plate_reading/biotek_cytation_backend.py b/pylabrobot/plate_reading/biotek_cytation_backend.py deleted file mode 100644 index c89a11cbf88..00000000000 --- a/pylabrobot/plate_reading/biotek_cytation_backend.py +++ /dev/null @@ -1,13 +0,0 @@ -import warnings - -from .agilent.biotek_cytation_backend import ( - Cytation5Backend, # noqa: F401 - Cytation5ImagingConfig, # noqa: F401 - CytationBackend, # noqa: F401 - CytationImagingConfig, # noqa: F401 -) - -warnings.warn( - "pylabrobot.plate_reading.biotek_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.plate_reading.agilent.biotek_backend instead.", -) diff --git a/pylabrobot/plate_reading/biotek_synergyh1_backend.py b/pylabrobot/plate_reading/biotek_synergyh1_backend.py deleted file mode 100644 index 8f6589ab25d..00000000000 --- a/pylabrobot/plate_reading/biotek_synergyh1_backend.py +++ /dev/null @@ -1,8 +0,0 @@ -import warnings - -from .agilent.biotek_synergyh1_backend import SynergyH1Backend # noqa: F401 - -warnings.warn( - "pylabrobot.plate_reading.biotek_synergyh1_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.plate_reading.agilent.biotek_synergyh1_backend instead.", -) diff --git a/pylabrobot/plate_reading/byonoy/byonoy_a96a.py b/pylabrobot/plate_reading/byonoy/byonoy_a96a.py deleted file mode 100644 index f30252e501d..00000000000 --- a/pylabrobot/plate_reading/byonoy/byonoy_a96a.py +++ /dev/null @@ -1,188 +0,0 @@ -from typing import Optional, Tuple - -from pylabrobot.plate_reading.byonoy.byonoy_backend import ByonoyAbsorbance96AutomateBackend -from pylabrobot.plate_reading.plate_reader import PlateReader -from pylabrobot.resources import Coordinate, PlateHolder, Resource, ResourceHolder -from pylabrobot.resources.barcode import Barcode -from pylabrobot.resources.rotation import Rotation - - -def byonoy_sbs_adapter(name: str) -> ResourceHolder: - """Create a Byonoy SBS adapter `ResourceHolder`. - - This helper returns a `ResourceHolder` describing the physical footprint of the - Byonoy SBS adapter and the default coordinate transform from the adapter frame - to its child frame. - - The adapter is modeled as a cuboid with fixed outer dimensions. - `child_location` encodes the child-frame origin offset assuming the SBS-adapter - is symmetrically centered ("cc") relative to the detection_unit "cc" alignment reference. - """ - return ResourceHolder( - name=name, - size_x=127.76, - size_y=85.48, - size_z=17.0, - child_location=Coordinate( - x=-(155.26 - 127.76) / 2, - y=-(95.48 - 85.48) / 2, - z=17.0, - ), - ) - - -class _ByonoyAbsorbanceReaderPlateHolder(PlateHolder): - """Custom plate holder that checks if the reader sits on the parent base. - This check is used to prevent crashes (moving plate onto holder while reader is on the base).""" - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - pedestal_size_z: float = 0, - child_location: Coordinate = Coordinate.zero(), - category: str = "plate_holder", - model: Optional[str] = None, - ): - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - pedestal_size_z=pedestal_size_z, - child_location=child_location, - category=category, - model=model, - ) - self._byonoy_base: Optional["ByonoyAbsorbanceBaseUnit"] = None - - def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: - if self._byonoy_base is None: - raise RuntimeError( - "Plate holder not assigned to a ByonoyAbsorbanceBaseUnit. This should not happen." - ) - - if self._byonoy_base.illumination_unit_holder.resource is not None: - raise RuntimeError( - f"Cannot drop resource {resource.name} onto plate holder while illumination unit is on the base. " - "Please remove the illumination unit from the base before dropping a resource." - ) - - super().check_can_drop_resource_here(resource, reassign=reassign) - - -class ByonoyAbsorbanceBaseUnit(Resource): - def __init__( - self, - name: str, - size_x: float = 155.26, - size_y: float = 95.48, - size_z: float = 18.5, - rotation: Optional[Rotation] = None, - category: Optional[str] = None, - model: Optional[str] = None, - barcode: Optional[Barcode] = None, - preferred_pickup_location: Optional[Coordinate] = None, - ): - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - rotation=rotation, - category=category, - model=model, - barcode=barcode, - preferred_pickup_location=preferred_pickup_location, - ) - - self.plate_holder = _ByonoyAbsorbanceReaderPlateHolder( - name=self.name + "_plate_holder", - size_x=127.76, # standard SBS footprint - size_y=85.59, - size_z=0, - child_location=Coordinate(x=22.5, y=5.0, z=16.0), - pedestal_size_z=0, - ) - self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) - - self.illumination_unit_holder = ResourceHolder( - name=self.name + "_illumination_unit_holder", - size_x=size_x, - size_y=size_y, - size_z=0, - child_location=Coordinate(x=0, y=0, z=14.1), - ) - self.assign_child_resource(self.illumination_unit_holder, location=Coordinate.zero()) - - def assign_child_resource( - self, resource: Resource, location: Optional[Coordinate], reassign: bool = True - ) -> None: - if isinstance(resource, _ByonoyAbsorbanceReaderPlateHolder): - if self.plate_holder._byonoy_base is not None: - raise ValueError("ByonoyBase can only have one plate holder assigned.") - self.plate_holder._byonoy_base = self - super().assign_child_resource(resource, location, reassign) - - def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: - raise RuntimeError( - "ByonoyBase does not support assigning child resources directly. " - "Use the plate_holder or illumination_unit_holder to assign plates and the illumination unit, respectively." - ) - - -class ByonoyAbsorbance96Automate(PlateReader, ByonoyAbsorbanceBaseUnit): - def __init__(self, name: str): - ByonoyAbsorbanceBaseUnit.__init__(self, name=name + "_base") - PlateReader.__init__( - self, - name=name + "_reader", - size_x=138, - size_y=95.7, - size_z=0, - backend=ByonoyAbsorbance96AutomateBackend(), - ) - - -def byonoy_a96a_detection_unit(name: str) -> ByonoyAbsorbance96Automate: - """Create a Byonoy A96A detection unit `PlateReader`. - - The detection unit is modeled as a fixed-size rectangular prism. - """ - - return ByonoyAbsorbance96Automate(name=name) - - -def byonoy_a96a_parking_unit(name: str) -> ByonoyAbsorbanceBaseUnit: - """Create a Byonoy A96A detection unit holder.""" - - return ByonoyAbsorbanceBaseUnit(name=name) - - -def byonoy_a96a_illumination_unit(name: str) -> Resource: - """ """ - size_x = 155.26 - size_y = 95.48 - return Resource( - name=name, - size_x=size_x, - size_y=size_y, - size_z=42.898, - model="Byonoy A96A Illumination Unit", - preferred_pickup_location=Coordinate(x=size_x / 2, y=size_y / 2, z=29.5), - ) - - -def byonoy_a96a(name: str, assign: bool = True) -> Tuple[ByonoyAbsorbance96Automate, Resource]: - """Creates a ByonoyBase and a PlateReader instance.""" - reader = byonoy_a96a_detection_unit( - name=name + "_reader", - ) - illumination_unit = byonoy_a96a_illumination_unit( - name=name + "_illumination_unit", - ) - if assign: - reader.illumination_unit_holder.assign_child_resource(illumination_unit) - return reader, illumination_unit diff --git a/pylabrobot/plate_reading/byonoy/byonoy_backend.py b/pylabrobot/plate_reading/byonoy/byonoy_backend.py deleted file mode 100644 index ca2ae684cf3..00000000000 --- a/pylabrobot/plate_reading/byonoy/byonoy_backend.py +++ /dev/null @@ -1,401 +0,0 @@ -import abc -import asyncio -import enum -import threading -import time -from typing import Dict, List, Optional - -from pylabrobot.io.binary import Reader, Writer -from pylabrobot.io.hid import HID -from pylabrobot.plate_reading.backend import PlateReaderBackend -from pylabrobot.resources import Plate, Well -from pylabrobot.utils.list import reshape_2d - - -class _ByonoyDevice(enum.Enum): - ABSORBANCE_96 = enum.auto() - LUMINESCENCE_96 = enum.auto() - - -class _ByonoyBase(PlateReaderBackend, metaclass=abc.ABCMeta): - """Base backend for Byonoy plate readers using HID communication. - Provides common functionality for different Byonoy machine types. - """ - - def __init__(self, pid: int, device_type: _ByonoyDevice) -> None: - self.io = HID(human_readable_device_name="Byonoy Plate Reader", vid=0x16D0, pid=pid) - self._background_thread: Optional[threading.Thread] = None - self._stop_background = threading.Event() - self._ping_interval = 1.0 # Send ping every second - self._sending_pings = False # Whether to actively send pings - self._device_type = device_type - - async def setup(self) -> None: - """Set up the plate reader. This should be called before any other methods.""" - - await self.io.setup() - - # Start background keep alive messages - self._stop_background.clear() - self._background_thread = threading.Thread(target=self._background_ping_worker, daemon=True) - self._background_thread.start() - - async def stop(self) -> None: - """Close all connections to the plate reader and make sure setup() can be called again.""" - - # Stop background keep alive messages - self._stop_background.set() - if self._background_thread and self._background_thread.is_alive(): - self._background_thread.join(timeout=2.0) - - await self.io.stop() - - def _assemble_command(self, report_id: int, payload: bytes, routing_info: bytes) -> bytes: - packet = Writer().u16(report_id).raw_bytes(payload).finish() - packet += b"\x00" * (62 - len(packet)) + routing_info # pad to 64 bytes - return packet - - async def send_command( - self, - report_id: int, - payload: bytes, - wait_for_response: bool = True, - routing_info: bytes = b"\x00\x00", - ) -> Optional[bytes]: - command = self._assemble_command(report_id, payload=payload, routing_info=routing_info) - - await self.io.write(command) - if not wait_for_response: - return None - - t0 = time.time() - while True: - if time.time() - t0 > 120: # read for 2 minutes max. typical is 1m5s. - raise TimeoutError("Reading luminescence data timed out after 2 minutes.") - - response = await self.io.read(64, timeout=30) - if len(response) == 0: - continue - - # if the first 2 bytes do not match, we continue reading - response_report_id = Reader(response).u16() - if report_id == response_report_id: - break - return response - - def _background_ping_worker(self) -> None: - """Background worker that sends periodic ping commands.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - loop.run_until_complete(self._ping_loop()) - finally: - loop.close() - - async def _ping_loop(self) -> None: - """Main ping loop that runs in the background thread.""" - while not self._stop_background.is_set(): - if self._sending_pings: - # don't read in background thread, data might get lost here. don't use send_command - payload = Writer().u8(1).finish() - cmd = self._assemble_command( - report_id=0x0040, # command id: HEARTBEAT_IN - payload=payload, - routing_info=b"\x00\x00", - ) - await self.io.write(cmd) - - self._stop_background.wait(self._ping_interval) - - def _start_background_pings(self) -> None: - self._sending_pings = True - - def _stop_background_pings(self) -> None: - self._sending_pings = False - - async def open(self) -> None: - raise NotImplementedError( - "byonoy cannot open by itself. you need to move the top module using a robot arm." - ) - - async def close(self, plate: Optional[Plate]) -> None: - raise NotImplementedError( - "byonoy cannot close by itself. you need to move the top module using a robot arm." - ) - - -class ByonoyAbsorbance96AutomateBackend(_ByonoyBase): - def __init__(self) -> None: - super().__init__(pid=0x1199, device_type=_ByonoyDevice.ABSORBANCE_96) - - async def setup(self, verbose: bool = False, **backend_kwargs): - """Set up the plate reader. This should be called before any other methods.""" - - # Call the base setup (opens HID) - await super().setup(**backend_kwargs) - - # After device is online, run reference initialisation - await self.initialize_measurements() - - self.available_wavelengths = await self.get_available_absorbance_wavelengths() - - async def get_available_absorbance_wavelengths(self) -> List[float]: - response = await self.send_command( - report_id=0x0330, - payload=b"\x00" * 60, # 30 x i16 - wait_for_response=True, - routing_info=b"\x80\x40", - ) - assert response is not None, "Failed to get available wavelengths." - - # Skip the first 2 bytes (report_id), then read 30 signed 16-bit integers - reader = Reader(response[2:]) - available_wavelengths = [reader.i16() for _ in range(30)] - return [w for w in available_wavelengths if w != 0] - - async def _run_abs_measurement(self, signal_wl: int, reference_wl: int, is_reference: bool): - """Perform an absorbance measurement or reference measurement. - This contains all shared logic between initialization and real measurements.""" - - # (1) SUPPORTED_REPORTS_IN (0x0010) - await self.send_command( - report_id=0x0010, - payload=b"\x00" * 60, # seq, seq_len, ids[29] - wait_for_response=False, - ) - - # (2) DEVICE_DATA_READ_IN (0x0200) - payload2 = ( - Writer() - .u16(7) # field_index - .u8(0) # flags - .raw_bytes(b"\x00" * 52) # data - .finish() - ) - await self.send_command( - report_id=0x0200, - payload=payload2, - wait_for_response=False, - ) - - # (3) ABS_TRIGGER_MEASUREMENT_OUT (0x0320) - payload3 = ( - Writer() - .i16(signal_wl) - .i16(reference_wl) - .u8(int(is_reference)) - .u8(0) # flags - .finish() - ) - await self.send_command( - report_id=0x0320, - payload=payload3, - wait_for_response=False, - routing_info=b"\x00\x40", - ) - - # (4) Collect chunks (report_id 0x0500) - rows: List[float] = [] - t0 = time.time() - - while True: - if time.time() - t0 > 120: - raise TimeoutError("Measurement timeout.") - - chunk = await self.io.read(64, timeout=30) - if len(chunk) == 0: - continue - - reader = Reader(chunk) - report_id = reader.u16() - - # Only handle the measurement packets - if report_id == 0x0500: - seq = reader.u8() - seq_len = reader.u8() - _ = reader.i16() # signal_wl_nm - _ = reader.i16() # reference_wl_nm - _ = reader.u32() # duration_ms - row = [reader.f32() for _ in range(12)] - _ = reader.u8() # flags - _ = reader.u8() # progress - - rows.extend(row) - - if seq == seq_len - 1: - break - - return rows - - async def initialize_measurements(self): - """Perform the reference ABS measurement required by the firmware.""" - - # Standard reference wavelength used by Byonoy app - # required startup protocol to initialize the photodiode reference - REFERENCE_WL = 0 - SIGNAL_WL = 660 - - await self._run_abs_measurement( - signal_wl=SIGNAL_WL, - reference_wl=REFERENCE_WL, - is_reference=True, - ) - - async def read_absorbance( - self, - plate: Plate, - wells: List[Well], - wavelength: int, - ) -> List[dict]: - """ - Measure sample absorbance in each well at the specified wavelength. - - Args: - wavelength: Signal wavelength in nanometers. - plate: The plate being read. Included for API uniformity. - wells: Subset of wells to return. If omitted, all 96 wells are returned. - """ - - assert wavelength in self.available_wavelengths, ( - f"Wavelength {wavelength} nm not in available wavelengths {self.available_wavelengths}." - ) - - rows = await self._run_abs_measurement( - signal_wl=wavelength, - reference_wl=0, - is_reference=False, - ) - - matrix = reshape_2d(rows, (8, 12)) - - # dictionary output for filtered wells - return [ - { - "wavelength": wavelength, - "time": time.time(), - "temperature": None, - "data": matrix, - } - ] - - async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float - ) -> List[dict]: - raise NotImplementedError("Absorbance plate reader does not support luminescence reading.") - - async def read_fluorescence( - self, - plate: Plate, - wells, - excitation_wavelength: int, - emission_wavelength: int, - focal_height: float, - ) -> List[dict]: - raise NotImplementedError("Absorbance plate reader does not support fluorescence reading.") - - -class ByonoyLuminescence96AutomateBackend(_ByonoyBase): - def __init__(self) -> None: - super().__init__(pid=0x119B, device_type=_ByonoyDevice.LUMINESCENCE_96) - - async def read_absorbance(self, plate, wells, wavelength) -> List[Dict]: - raise NotImplementedError( - "Luminescence plate reader does not support absorbance reading. Use ByonoyAbsorbance96Automate instead." - ) - - async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 2 - ) -> List[Dict]: - """integration_time: in seconds, default 2 s""" - - # SUPPORTED_REPORTS_IN (0x0010) - await self.send_command( - report_id=0x0010, - payload=b"\x00" * 60, # seq, seq_len, ids[29] - wait_for_response=False, - ) - - # DEVICE_DATA_READ_IN (0x0200) - payload2 = ( - Writer() - .u16(7) # field_index - .u8(0) # flags - .raw_bytes(b"\x00" * 52) # data - .finish() - ) - await self.send_command( - report_id=0x0200, - payload=payload2, - wait_for_response=False, - ) - - # LUM_TRIGGER_MEASUREMENT_OUT (0x0340) - payload3 = ( - Writer() - .i32(int(integration_time * 1000 * 1000)) # integration_time_us - .raw_bytes(b"\xff" * 12) # channels_selected - .u8(0) # is_reference_measurement - .u8(0) # flags - .finish() - ) - await self.send_command( - report_id=0x0340, - payload=payload3, - wait_for_response=False, - ) - - t0 = time.time() - all_rows: List[float] = [] - - while True: - if time.time() - t0 > 120: # read for 2 minutes max. typical is 1m5s. - raise TimeoutError("Reading luminescence data timed out after 2 minutes.") - - chunk = await self.io.read(64, timeout=30) - if len(chunk) == 0: - continue - - reader = Reader(chunk) - report_id = reader.u16() - - if report_id == 0x0600: # REP_LUM96_MEASUREMENT_IN - seq = reader.u8() - seq_len = reader.u8() - _ = reader.u32() # integration_time_us - _ = reader.u32() # duration_ms - row = [reader.f32() for _ in range(12)] - _ = reader.u8() # flags - _ = reader.u8() # progress - - all_rows.extend(row) - - if seq == seq_len - 1: - break - - hybrid_result = all_rows[96 * 0 : 96 * 1] - _ = all_rows[96 * 1 : 96 * 2] # counting_result - _ = all_rows[96 * 2 : 96 * 3] # sampling_result - _ = all_rows[96 * 3 : 96 * 4] # micro_counting_result - _ = all_rows[96 * 4 : 96 * 5] # micro_integration_result - _ = all_rows[96 * 5 : 96 * 6] # repetition_count - _ = all_rows[96 * 6 : 96 * 7] # integration_times - _ = all_rows[96 * 7 : 96 * 8] # below_breakdown_measurement - - return [ - { - "time": time.time(), - "temperature": None, - "data": reshape_2d(hybrid_result, (8, 12)), - } - ] - - async def read_fluorescence( - self, - plate: Plate, - wells, - excitation_wavelength: int, - emission_wavelength: int, - focal_height: float, - ) -> List[Dict]: - raise NotImplementedError("Fluorescence plate reader does not support fluorescence reading.") diff --git a/pylabrobot/plate_reading/byonoy/byonoy_l96.py b/pylabrobot/plate_reading/byonoy/byonoy_l96.py deleted file mode 100644 index c9b080c5b33..00000000000 --- a/pylabrobot/plate_reading/byonoy/byonoy_l96.py +++ /dev/null @@ -1,176 +0,0 @@ -from typing import Optional, Tuple - -from pylabrobot.plate_reading.byonoy.byonoy_backend import ByonoyLuminescence96AutomateBackend -from pylabrobot.plate_reading.plate_reader import PlateReader -from pylabrobot.resources import Coordinate, PlateHolder, Resource, ResourceHolder -from pylabrobot.resources.barcode import Barcode -from pylabrobot.resources.rotation import Rotation - - -class _ByonoyLuminescenceReaderPlateHolder(PlateHolder): - """Custom plate holder that checks if the reader sits on the parent base. - This check is used to prevent crashes (moving plate onto holder while reader is on the base).""" - - def __init__( - self, - name: str, - child_location: Coordinate = Coordinate.zero(), - category: str = "plate_holder", - model: Optional[str] = None, - ): - super().__init__( - name=name, - size_x=127.76, - size_y=85.59, - size_z=0, - pedestal_size_z=0, - child_location=child_location, - category=category, - model=model, - ) - self._byonoy_base: Optional["ByonoyLuminescenceBaseUnit"] = None - - def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: - if self._byonoy_base is None: - raise RuntimeError( - "Plate holder not assigned to a ByonoyLuminescenceBaseUnit. This should not happen." - ) - - if self._byonoy_base.reader_unit_holder.resource is not None: - raise RuntimeError( - f"Cannot drop resource {resource.name} onto plate holder while reader unit is on the base. " - "Please remove the reader unit from the base before dropping a resource." - ) - - super().check_can_drop_resource_here(resource, reassign=reassign) - - -class ByonoyLuminescenceBaseUnit(Resource): - """Base unit for the Byonoy L96/L96A luminescence reader. - - The base unit is a simple resource that holds a plate. The reader unit sits on top of it. - """ - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - plate_holder_child_location: Coordinate, - reader_unit_holder_child_location: Coordinate, - rotation: Optional[Rotation] = None, - category: Optional[str] = None, - model: Optional[str] = None, - barcode: Optional[Barcode] = None, - ): - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - rotation=rotation, - category=category, - model=model, - barcode=barcode, - ) - - self.plate_holder = _ByonoyLuminescenceReaderPlateHolder( - name=self.name + "_plate_holder", - child_location=plate_holder_child_location, - ) - self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) - - self.reader_unit_holder = ResourceHolder( - name=self.name + "_reader_unit_holder", - size_x=size_x, - size_y=size_y, - size_z=0, - child_location=reader_unit_holder_child_location, - ) - self.assign_child_resource(self.reader_unit_holder, location=Coordinate.zero()) - - def assign_child_resource( - self, resource: Resource, location: Optional[Coordinate], reassign: bool = True - ) -> None: - if isinstance(resource, _ByonoyLuminescenceReaderPlateHolder): - if self.plate_holder._byonoy_base is not None: - raise ValueError("ByonoyBase can only have one plate holder assigned.") - self.plate_holder._byonoy_base = self - super().assign_child_resource(resource, location, reassign) - - def check_can_drop_resource_here(self, resource: Resource, *, reassign: bool = True) -> None: - raise RuntimeError( - "ByonoyBase does not support assigning child resources directly. " - "Use the plate_holder or reader_unit_holder to assign plates and the reader unit, respectively." - ) - - -class ByonoyLuminescence96Automate(PlateReader): - """Byonoy L96/L96A luminescence plate reader unit. - - This is the reader unit that sits on top of the base unit. - """ - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - preferred_pickup_location: Optional[Coordinate] = None, - ): - super().__init__( - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - backend=ByonoyLuminescence96AutomateBackend(), - model="Byonoy L96 Reader Unit", - preferred_pickup_location=preferred_pickup_location, - ) - - -def byonoy_l96_reader_unit(name: str) -> ByonoyLuminescence96Automate: - """Create a Byonoy L96 reader unit `PlateReader`. - - Note: L96 (non-automate) does not have a preferred pickup location. - """ - return ByonoyLuminescence96Automate( - name=name, - size_x=139.7, # caliper - size_y=97.5, # caliper - size_z=35, # force z probing - preferred_pickup_location=None, - ) - - -def byonoy_l96_base_unit(name: str) -> ByonoyLuminescenceBaseUnit: - """Create a Byonoy L96 base unit.""" - return ByonoyLuminescenceBaseUnit( - name=name, - size_x=139.7, # caliper - size_y=97.5, # caliper - size_z=9.4, # force z probing - plate_holder_child_location=Coordinate(x=6.25, y=6.1, z=2.64), # caliper - reader_unit_holder_child_location=Coordinate(x=0, y=0, z=7.2), # z = 42.2 - 35 - ) - - -def byonoy_l96( - name: str, assign: bool = True -) -> Tuple[ByonoyLuminescenceBaseUnit, ByonoyLuminescence96Automate]: - """Creates a ByonoyLuminescenceBaseUnit and a PlateReader instance for L96 (non-automate). - - Args: - name: Base name for the resources. - assign: If True, the reader unit is assigned to the base unit's reader_unit_holder. - - Returns: - A tuple of (base_unit, reader_unit). - """ - base_unit = byonoy_l96_base_unit(name=name + "_base") - reader_unit = byonoy_l96_reader_unit(name=name + "_reader") - if assign: - base_unit.reader_unit_holder.assign_child_resource(reader_unit) - return base_unit, reader_unit diff --git a/pylabrobot/plate_reading/chatterbox.py b/pylabrobot/plate_reading/chatterbox.py deleted file mode 100644 index 4def8f3a806..00000000000 --- a/pylabrobot/plate_reading/chatterbox.py +++ /dev/null @@ -1,123 +0,0 @@ -import time -from typing import Dict, List, Optional - -from pylabrobot.plate_reading.backend import PlateReaderBackend -from pylabrobot.resources import Plate, Well - - -class PlateReaderChatterboxBackend(PlateReaderBackend): - """An abstract class for a plate reader. Plate readers are devices that can read luminescence, - absorbance, or fluorescence from a plate.""" - - def __init__(self): - self.dummy_luminescence: List[List[Optional[float]]] = [[0.0] * 12] * 8 - self.dummy_absorbance: List[List[Optional[float]]] = [[0.0] * 12] * 8 - self.dummy_fluorescence: List[List[Optional[float]]] = [[0.0] * 12] * 8 - - async def setup(self) -> None: - print("Setting up the plate reader.") - - async def stop(self) -> None: - print("Stopping the plate reader.") - - async def open(self) -> None: - print("Opening the plate reader.") - - async def close(self, plate: Optional[Plate]) -> None: - print(f"Closing the plate reader with plate, {plate}.") - - def _print_plate_reading_wells(self, result: List[List[Optional[float]]]) -> None: - print("Read the following wells:") - - cell_width = 7 - precision = 3 - - def fmt_cell(val: Optional[float]) -> str: - if val is None: - return "" # print empty for None - return f"{val:.{precision}f}" - - def row_label(r: int) -> str: - return "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[r] if r < 26 else "?" - - num_rows = len(result) - num_cols = max(len(row) for row in result) - - # Header - top = " " * (len(row_label(num_cols - 1)) + 1) + "|" - for c in range(num_cols): - top += f"{c + 1:>{cell_width}}|" - print(top) - - # Divider - print("-" * len(top)) - - # Rows - for r in range(num_rows): - line = f"{row_label(r)} ".rjust(len(row_label(num_cols - 1)) + 1) + "|" - for c in range(num_cols): - line += f"{fmt_cell(result[r][c]):>{cell_width}}|" - print(line) - - def _mask_result( - self, result: List[List[Optional[float]]], wells: List[Well], plate: Plate - ) -> List[List[Optional[float]]]: - masked: List[List[Optional[float]]] = [ - [None for _ in range(plate.num_items_x)] for _ in range(plate.num_items_y) - ] - for well in wells: - r, c = well.get_row(), well.get_column() - if r < plate.num_items_y and c < plate.num_items_x: - masked[r][c] = result[r][c] - return masked - - async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float - ) -> List[Dict]: - print(f"Reading luminescence at focal height {focal_height}.") - result = self._mask_result(self.dummy_luminescence, wells, plate) - self._print_plate_reading_wells(result) - return [ - { - "time": time.time(), - "temperature": float("nan"), - "data": result, - "em_wavelength": 0, - } - ] - - async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int) -> List[Dict]: - print(f"Reading absorbance at wavelength {wavelength}.") - result = self._mask_result(self.dummy_absorbance, wells, plate) - self._print_plate_reading_wells(result) - return [ - { - "time": time.time(), - "temperature": float("nan"), - "data": result, - "wavelength": wavelength, - } - ] - - async def read_fluorescence( - self, - plate: Plate, - wells: List[Well], - excitation_wavelength: int, - emission_wavelength: int, - focal_height: float, - ) -> List[Dict]: - print( - f"Reading fluorescence at excitation wavelength {excitation_wavelength}, emission wavelength {emission_wavelength}, and focal height {focal_height}." - ) - result = self._mask_result(self.dummy_fluorescence, wells, plate) - self._print_plate_reading_wells(result) - return [ - { - "time": time.time(), - "temperature": float("nan"), - "data": result, - "ex_wavelength": excitation_wavelength, - "em_wavelength": emission_wavelength, - } - ] diff --git a/pylabrobot/plate_reading/imager.py b/pylabrobot/plate_reading/imager.py deleted file mode 100644 index c55e0737d7f..00000000000 --- a/pylabrobot/plate_reading/imager.py +++ /dev/null @@ -1,367 +0,0 @@ -import logging -import math -import time -from typing import Any, Awaitable, Callable, Coroutine, Dict, Literal, Optional, Tuple, Union, cast - -from pylabrobot.machines import Machine, need_setup_finished -from pylabrobot.plate_reading.backend import ImagerBackend -from pylabrobot.plate_reading.standard import ( - AutoExposure, - AutoFocus, - Exposure, - FocalPosition, - Gain, - Image, - ImagingMode, - ImagingResult, - NoPlateError, - Objective, -) -from pylabrobot.resources import Plate, Resource, Rotation, Well - -try: - import cv2 # type: ignore - - CV2_AVAILABLE = True -except ImportError as e: - cv2 = None # type: ignore - CV2_AVAILABLE = False - _CV2_IMPORT_ERROR = e - -try: - import numpy as np # type: ignore -except ImportError: - np = None # type: ignore[assignment] - - -logger = logging.getLogger(__name__) - - -async def _golden_ratio_search( - func: Callable[..., Coroutine[Any, Any, float]], a: float, b: float, tol: float, timeout: float -): - """Golden ratio search to maximize a unimodal function `func` over the interval [a, b].""" - # thanks chat - phi = (1 + np.sqrt(5)) / 2 # Golden ratio - - c = b - (b - a) / phi - d = a + (b - a) / phi - - cache: Dict[float, float] = {} - - async def cached_func(x: float) -> float: - x = round(x / tol) * tol # round x to units of tol - if x not in cache: - cache[x] = await func(x) - return cache[x] - - t0 = time.time() - iteration = 0 - while abs(b - a) > tol: - if (await cached_func(c)) > (await cached_func(d)): - b = d - else: - a = c - c = b - (b - a) / phi - d = a + (b - a) / phi - if time.time() - t0 > timeout: - raise TimeoutError("Timeout while searching for optimal focus position") - iteration += 1 - logger.debug("Golden ratio search (autofocus) iteration %d, a=%s, b=%s", iteration, a, b) - - return (b + a) / 2 - - -class Imager(Resource, Machine): - """Microscope""" - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - backend: ImagerBackend, - rotation: Optional[Rotation] = None, - category: Optional[str] = None, - model: Optional[str] = None, - ): - Resource.__init__( - self, - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - rotation=rotation, - category=category, - model=model, - ) - Machine.__init__(self, backend=backend) - self.backend: ImagerBackend = backend # fix type - - self.register_will_assign_resource_callback(self._will_assign_resource) - - def _will_assign_resource(self, resource: Resource): - if len(self.children) >= 1: - raise ValueError( - f"Imager {self} already has a plate assigned (attempting to assign {resource})" - ) - - def get_plate(self) -> Plate: - if len(self.children) == 0: - raise NoPlateError("There is no plate in the plate reader.") - return cast(Plate, self.children[0]) - - async def _capture_auto_exposure( - self, - well: Union[Well, Tuple[int, int]], - mode: ImagingMode, - objective: Objective, - auto_exposure: AutoExposure, - focal_height: float, - gain: float, - **backend_kwargs, - ) -> ImagingResult: - """ - Capture an image with auto exposure. - - This function will iteratively adjust the exposure time until a good exposure is found. - It uses the provided `evaluate_exposure` function to determine if the exposure is good, too high, or too low. - It uses a weighted binary search to find the optimal exposure time. The search is weighted by exposure time, - meaning that instead of splitting the range in half, we split the range at the point that equalizes the integral - of the exposure time on both sides (this works out to be equal to the root mean square of the endpoints). - """ - - if focal_height == "auto": - raise ValueError("Focal height must be specified for auto exposure") - if gain == "auto": - raise ValueError("Gain must be specified for auto exposure") - - def _rms_split(low: float, high: float) -> float: - """Split point that equalizes ∫t dt on both sides (RMS of endpoints).""" - if low == high: - return low - return math.sqrt((low**2 + high**2) / 2) - - low, high = auto_exposure.low, auto_exposure.high - - rounds = 0 - while high - low > 1e-3: - if auto_exposure.max_rounds is not None and rounds >= auto_exposure.max_rounds: - raise ValueError("Exceeded maximum number of rounds") - rounds += 1 - - p = _rms_split(low, high) - res = await self.capture( - well=well, - mode=mode, - objective=objective, - exposure_time=p, - focal_height=focal_height, - gain=gain, - **backend_kwargs, - ) - assert len(res.images) == 1, "Expected exactly one image to be returned" - im = res.images[0] - evaluation = await auto_exposure.evaluate_exposure(im) - - if evaluation == "good": - return res - if evaluation == "lower": - high = p - elif evaluation == "higher": - low = p - else: - raise ValueError(f"Unexpected evaluation result: {evaluation}") - - raise RuntimeError("Failed to find a good exposure time.") - - async def _capture_auto_focus( - self, - well: Union[Well, Tuple[int, int]], - mode: ImagingMode, - objective: Objective, - exposure_time: float, - auto_focus: AutoFocus, - gain: float, - **backend_kwargs, - ) -> ImagingResult: - async def local_capture(focal_height: float) -> ImagingResult: - return await self.capture( - well=well, - mode=mode, - objective=objective, - exposure_time=exposure_time, - focal_height=focal_height, - gain=gain, - **backend_kwargs, - ) - - async def capture_and_evaluate(focal_height: float) -> float: - res = await local_capture(focal_height) - return auto_focus.evaluate_focus(res.images[0]) - - # Use golden ratio search to find the best focus value - best_focal_height = await _golden_ratio_search( - func=capture_and_evaluate, - a=auto_focus.low, - b=auto_focus.high, - tol=auto_focus.tolerance, # 1 micron - timeout=auto_focus.timeout, - ) - return await local_capture(best_focal_height) - - @need_setup_finished - async def capture( - self, - well: Union[Well, Tuple[int, int]], - mode: ImagingMode, - objective: Objective, - exposure_time: Union[Exposure, AutoExposure] = "machine-auto", - focal_height: FocalPosition = "machine-auto", - gain: Gain = "machine-auto", - **backend_kwargs, - ) -> ImagingResult: - if exposure_time != "machine-auto" and not isinstance( - exposure_time, (int, float, AutoExposure) - ): - raise TypeError(f"Invalid exposure time: {exposure_time}") - if ( - not isinstance(focal_height, (int, float)) - and focal_height != "machine-auto" - and not isinstance(focal_height, AutoFocus) - ): - raise TypeError(f"Invalid focal height: {focal_height}") - - if isinstance(well, tuple): - row, column = well - else: - idx = cast(Plate, well.parent).index_of_item(well) - if idx is None: - raise ValueError(f"Well {well} not in plate {well.parent}") - row, column = divmod(idx, cast(Plate, well.parent).num_items_x) - - if isinstance(exposure_time, AutoExposure): - assert focal_height != "machine-auto", "Focal height must be specified for auto exposure" - assert gain != "machine-auto", "Gain must be specified for auto exposure" - return await self._capture_auto_exposure( - well=well, - mode=mode, - objective=objective, - auto_exposure=exposure_time, - focal_height=focal_height, - gain=gain, - **backend_kwargs, - ) - - if isinstance(focal_height, AutoFocus): - assert isinstance(exposure_time, (int, float)), ( - "Exposure time must be specified for auto focus" - ) - assert gain != "machine-auto", "Gain must be specified for auto focus" - return await self._capture_auto_focus( - well=well, - mode=mode, - objective=objective, - exposure_time=exposure_time, - auto_focus=focal_height, - gain=gain, - **backend_kwargs, - ) - - return await self.backend.capture( - row=row, - column=column, - mode=mode, - objective=objective, - exposure_time=exposure_time, - focal_height=focal_height, - gain=gain, - plate=self.get_plate(), - **backend_kwargs, - ) - - -def max_pixel_at_fraction( - fraction: float, margin: float -) -> Callable[[Image], Awaitable[Literal["higher", "lower", "good"]]]: - """The maximum pixel value in a given image should be a fraction of the maximum possible pixel value (eg 255 for 8-bit images). - - Args: - fraction: the desired fraction of the actual maximum pixel value over the theoretically maximum pixel value (e.g. 0.8 for 80%). If it is an 8-bit image, the maximum value would be 0.8 * 255 = 204. - margin: the margin of error that is accepted. A fraction of the theoretical maximum pixel value, e.g. 0.05 for 5%, so the maximum pixel value should be between 0.75 * 255 and 0.85 * 255. - """ - - if np is None: - raise ImportError("numpy is required for max_pixel_at_fraction") - - async def evaluate_exposure(im) -> Literal["higher", "lower", "good"]: - array = np.array(im, dtype=np.float32) - value = np.max(array) - (255.0 * fraction) - margin_value = 255.0 * margin - if abs(value) <= margin_value: - return "good" - # lower the exposure time if the max pixel value is too high - return "lower" if value > 0 else "higher" - - return evaluate_exposure - - -def fraction_overexposed( - fraction: float, margin: float, max_pixel_value: int = 255 -) -> Callable[[Image], Awaitable[Literal["higher", "lower", "good"]]]: - """A certain fraction of pixels in the image should be overexposed (e.g. 0.5%). - - This is useful for images that are not well illuminated, as it ensures that a certain fraction of pixels is overexposed, which can help with image quality. - - Args: - fraction: the desired fraction of pixels that should be overexposed (e.g. 0.005 for 0.5%). Overexposed is defined as pixels with a value greater than the maximum pixel value (e.g. 255 for 8-bit images). You can customize this number if needed. - margin: the margin of error for the fraction of pixels that should be overexposed (e.g. 0.001 for 0.1%, so the fraction of overexposed pixels should be between 0.004 and 0.006). - max_pixel_value: the maximum pixel value for the image (e.g. 255 for 8-bit images). You can override it to change the definition of "overexposed" pixels. - """ - - if np is None: - raise ImportError("numpy is required for fraction_overexposed") - - async def evaluate_exposure(im) -> Literal["higher", "lower", "good"]: - # count the number of pixels that are overexposed - arr = np.asarray(im, dtype=np.uint8) - actual_fraction = np.count_nonzero(arr > max_pixel_value) / arr.size - lower_bound, upper_bound = fraction - margin, fraction + margin - if lower_bound <= actual_fraction <= upper_bound: - return "good" - # too many saturated pixels -> shorten exposure - return "lower" if (actual_fraction - fraction) > 0 else "higher" - - return evaluate_exposure - - -def evaluate_focus_nvmg_sobel(image: Image) -> float: - """Evaluate the focus of an image using the Normalized Variance of the Gradient Magnitude (NVMG) method with Sobel filters. - - I think Chat invented this method. - - Only uses the center 50% of the image to avoid edge effects. - """ - if not CV2_AVAILABLE: - raise RuntimeError( - f"cv2 needs to be installed for auto focus. Import error: {_CV2_IMPORT_ERROR}" - ) - - # cut out 25% on each side - np_image = np.array(image, dtype=np.float64) - height, width = np_image.shape[:2] - crop_height = height // 4 - crop_width = width // 4 - np_image = np_image[crop_height : height - crop_height, crop_width : width - crop_width] - - # NVMG: Normalized Variance of the Gradient Magnitude - # Chat invented this i think - sobel_x = cv2.Sobel(np_image, cv2.CV_64F, 1, 0, ksize=3) - sobel_y = cv2.Sobel(np_image, cv2.CV_64F, 0, 1, ksize=3) - gradient_magnitude = np.sqrt(sobel_x**2 + sobel_y**2) - - mean_gm = np.mean(gradient_magnitude) - var_gm = np.var(gradient_magnitude) - sharpness = var_gm / (mean_gm + 1e-6) - return cast(float, sharpness) diff --git a/pylabrobot/plate_reading/molecular_devices_backend.py b/pylabrobot/plate_reading/molecular_devices_backend.py deleted file mode 100644 index d3ae0840ef5..00000000000 --- a/pylabrobot/plate_reading/molecular_devices_backend.py +++ /dev/null @@ -1,11 +0,0 @@ -import warnings - -from .molecular_devices.backend import ( # noqa: F401 - MolecularDevicesBackend, - MolecularDevicesSettings, -) - -warnings.warn( - "pylabrobot.plate_reading.molecular_devices_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.plate_reading.molecular_devices.molecular_devices_backend instead.", -) diff --git a/pylabrobot/plate_reading/spectramax_384_plus_backend.py b/pylabrobot/plate_reading/spectramax_384_plus_backend.py deleted file mode 100644 index b209792ed7b..00000000000 --- a/pylabrobot/plate_reading/spectramax_384_plus_backend.py +++ /dev/null @@ -1,10 +0,0 @@ -import warnings - -from .molecular_devices.spectramax_384_plus_backend import ( - MolecularDevicesSpectraMax384PlusBackend, # noqa: F401 -) - -warnings.warn( - "pylabrobot.plate_reading.spectramax_384_plus_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.plate_reading.molecular_devices.spectramax_384_plus_backend instead.", -) diff --git a/pylabrobot/plate_reading/spectramax_m5_backend.py b/pylabrobot/plate_reading/spectramax_m5_backend.py deleted file mode 100644 index b58cf75ae0a..00000000000 --- a/pylabrobot/plate_reading/spectramax_m5_backend.py +++ /dev/null @@ -1,10 +0,0 @@ -import warnings - -from .molecular_devices.spectramax_m5_backend import ( - MolecularDevicesSpectraMaxM5Backend, # noqa: F401 -) - -warnings.warn( - "pylabrobot.plate_reading.spectramax_m5_backend is deprecated and will be removed in a future release. " - "Please use pylabrobot.plate_reading.molecular_devices.spectramax_m5_backend instead.", -) diff --git a/pylabrobot/qinstruments/__init__.py b/pylabrobot/qinstruments/__init__.py new file mode 100644 index 00000000000..bc0362513f8 --- /dev/null +++ b/pylabrobot/qinstruments/__init__.py @@ -0,0 +1,16 @@ +from .bioshake import ( + BioShake, + BioShake3000, + BioShake3000Elm, + BioShake3000ElmDWP, + BioShake3000T, + BioShake3000TElm, + BioShake5000Elm, + BioShakeBackend, + BioShakeD30Elm, + BioShakeD30TElm, + BioShakeQ1, + BioShakeQ2, + ColdPlate, + Heatplate, +) diff --git a/pylabrobot/qinstruments/bioshake.py b/pylabrobot/qinstruments/bioshake.py new file mode 100644 index 00000000000..4a0b6dbb72e --- /dev/null +++ b/pylabrobot/qinstruments/bioshake.py @@ -0,0 +1,499 @@ +import asyncio +from typing import Optional, Union + +from pylabrobot.capabilities.shaking import ShakerBackend, ShakingCapability +from pylabrobot.capabilities.temperature_controlling import ( + TemperatureControlCapability, + TemperatureControllerBackend, +) +from pylabrobot.device import Device, DeviceBackend +from pylabrobot.io.serial import Serial +from pylabrobot.resources import Coordinate +from pylabrobot.resources.carrier import PlateHolder + +try: + import serial + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e + + +class BioShakeBackend(TemperatureControllerBackend, ShakerBackend): + """Backend for all QInstruments BioShake models. + + All models share the same firmware. Commands that are not supported by a given + model will return an error from the device. + """ + + def __init__(self, port: str, timeout: int = 60, supports_active_cooling: bool = False): + if not HAS_SERIAL: + raise RuntimeError(f"pyserial is required for BioShake. Import error: {_SERIAL_IMPORT_ERROR}") + + self.port = port + self.timeout = timeout + self._supports_active_cooling = supports_active_cooling + self.io = Serial( + human_readable_device_name="QInstruments BioShake", + port=self.port, + baudrate=9600, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + write_timeout=10, + timeout=self.timeout, + ) + + async def _send_command(self, cmd: str, delay: float = 0.5, timeout: float = 2): + try: + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() + + await self.io.write((cmd + "\r").encode("ascii")) + await asyncio.sleep(delay) + + try: + response = await asyncio.wait_for(self.io.readline(), timeout=timeout) + except asyncio.TimeoutError: + raise RuntimeError(f"Timed out waiting for response to '{cmd}'") + + decoded = response.decode("ascii", errors="ignore").strip() + + if not decoded: + raise RuntimeError(f"No response for '{cmd}'") + + if decoded.startswith("e"): + raise RuntimeError(f"Device returned error for '{cmd}': '{decoded}'") + + if decoded.startswith("u ->"): + raise NotImplementedError(f"'{cmd}' not supported: '{decoded}'") + + if decoded.lower().startswith("ok"): + return None + + return decoded + + except Exception as e: + raise RuntimeError(f"Unexpected error while sending '{cmd}': {type(e).__name__}: {e}") from e + + async def setup(self, skip_home: bool = False): + await DeviceBackend.setup(self) + await self.io.setup() + if not skip_home: + await self.reset() + await asyncio.sleep(4) + await self.home() + + async def stop(self): + await DeviceBackend.stop(self) + await self.io.stop() + + async def reset(self): + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() + + await self.io.write(("resetDevice\r").encode("ascii")) + + start = asyncio.get_event_loop().time() + max_seconds = 30 + + while True: + if asyncio.get_event_loop().time() - start > max_seconds: + raise TimeoutError("Reset did not complete in time") + + try: + response = await asyncio.wait_for(self.io.readline(), timeout=2) + decoded = response.decode("ascii", errors="ignore").strip() + await asyncio.sleep(0.1) + + if len(decoded) > 0: + if "Initialization complete" in decoded: + break + + except asyncio.TimeoutError: + continue + + async def home(self): + await self._send_command(cmd="shakeGoHome", delay=5) + + # -- shaking + + async def start_shaking(self, speed: float, acceleration: Union[int, float] = 0): + if isinstance(speed, float): + if not speed.is_integer(): + raise ValueError(f"Speed must be a whole number, not {speed}") + speed = int(speed) + if not isinstance(speed, int): + raise TypeError( + f"Speed must be an integer or a whole number float, not {type(speed).__name__}" + ) + + min_speed = int(float(await self._send_command(cmd="getShakeMinRpm", delay=0.2))) + max_speed = int(float(await self._send_command(cmd="getShakeMaxRpm", delay=0.2))) + + assert min_speed <= speed <= max_speed, ( + f"Speed {speed} RPM is out of range. Allowed range is {min_speed}-{max_speed} RPM" + ) + + await self._send_command(cmd=f"setShakeTargetSpeed{speed}") + + if isinstance(acceleration, float): + if not acceleration.is_integer(): + raise ValueError(f"Acceleration must be a whole number, not {acceleration}") + acceleration = int(acceleration) + if not isinstance(acceleration, int): + raise TypeError( + f"Acceleration must be an integer or a whole number float, not {type(acceleration).__name__}" + ) + + min_accel = int(float(await self._send_command(cmd="getShakeAccelerationMin", delay=0.2))) + max_accel = int(float(await self._send_command(cmd="getShakeAccelerationMax", delay=0.2))) + + assert min_accel <= acceleration <= max_accel, ( + f"Acceleration {acceleration} seconds is out of range. " + f"Allowed range is {min_accel}-{max_accel} seconds" + ) + + await self._send_command(cmd=f"setShakeAcceleration{acceleration}", delay=0.2) + await self._send_command(cmd="shakeOn", delay=0.2) + + async def stop_shaking(self, deceleration: Union[int, float] = 0): + if isinstance(deceleration, float): + if not deceleration.is_integer(): + raise ValueError(f"Deceleration must be a whole number, not {deceleration}") + deceleration = int(deceleration) + if not isinstance(deceleration, int): + raise TypeError( + f"Deceleration must be an integer or a whole number float, " + f"not {type(deceleration).__name__}" + ) + + min_decel = int(float(await self._send_command(cmd="getShakeAccelerationMin", delay=0.2))) + max_decel = int(float(await self._send_command(cmd="getShakeAccelerationMax", delay=0.2))) + + assert min_decel <= deceleration <= max_decel, ( + f"Deceleration {deceleration} seconds is out of range. " + f"Allowed range is {min_decel}-{max_decel} seconds" + ) + + await self._send_command(cmd=f"setShakeAcceleration{deceleration}", delay=0.2) + await self._send_command(cmd="shakeOff", delay=0.2) + + # The firmware needs the motor to fully decelerate before ELM can operate. + await asyncio.sleep(3) + + @property + def supports_locking(self) -> bool: + return True + + async def lock_plate(self): + await self._send_command(cmd="setElmLockPos", delay=0.3) + + async def unlock_plate(self): + await self._send_command(cmd="setElmUnlockPos", delay=0.3) + + # -- temperature + + @property + def supports_active_cooling(self) -> bool: + return self._supports_active_cooling + + async def set_temperature(self, temperature: float): + min_temp = int(float(await self._send_command(cmd="getTempMin", delay=0.2))) + max_temp = int(float(await self._send_command(cmd="getTempMax", delay=0.2))) + + assert min_temp <= temperature <= max_temp, ( + f"Temperature {temperature} C is out of range. Allowed range is {min_temp}-{max_temp} C." + ) + + temperature_tenths = temperature * 10 + + if isinstance(temperature_tenths, float): + if not temperature_tenths.is_integer(): + raise ValueError(f"Temperature must be a whole number in 1/10 C, not {temperature_tenths}") + temperature_tenths = int(temperature_tenths) + + await self._send_command(cmd=f"setTempTarget{temperature_tenths}", delay=0.2) + await self._send_command(cmd="tempOn", delay=0.2) + + async def get_current_temperature(self) -> float: + response = await self._send_command(cmd="getTempActual", delay=0.2) + return float(response) + + async def deactivate(self): + await self._send_command(cmd="tempOff", delay=0.2) + + +class BioShake(PlateHolder, Device): + """QInstruments BioShake base class. + + Use a subclass for your specific model. Capabilities (``tc``, ``shaker``) + are only present on subclasses whose hardware supports them. + """ + + def __init__( + self, + name: str, + size_x: float, + size_y: float, + size_z: float, + backend: BioShakeBackend, + child_location: Coordinate, + pedestal_size_z: float, + category: str = "bioshake", + model: Optional[str] = None, + ): + PlateHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + child_location=child_location, + pedestal_size_z=pedestal_size_z, + category=category, + model=model, + ) + Device.__init__(self, backend=backend) + self._backend: BioShakeBackend = backend + + def serialize(self) -> dict: + return { + **Device.serialize(self), + **PlateHolder.serialize(self), + } + + +# -- Per-model classes -- +# Shaking only + + +class BioShake3000(BioShake): + """BioShake 3000 - shaking 200-3000 rpm, no ELM, no heating.""" + + def __init__(self, name: str, port: str): + raise NotImplementedError("BioShake3000 is missing resource definition.") + super().__init__( + name=name, + backend=BioShakeBackend(port=port), + size_x=0, + size_y=0, + size_z=0, # TODO + child_location=Coordinate(0, 0, 0), # TODO + pedestal_size_z=0, # TODO + ) + self.shaker = ShakingCapability(backend=self._backend) + self._capabilities = [self.shaker] + + +class BioShake3000Elm(BioShake): + """BioShake 3000 elm - shaking 200-3000 rpm, ELM, no heating.""" + + def __init__(self, name: str, port: str): + raise NotImplementedError("BioShake3000Elm is missing resource definition.") + super().__init__( + name=name, + backend=BioShakeBackend(port=port), + size_x=0, + size_y=0, + size_z=0, # TODO + child_location=Coordinate(0, 0, 0), # TODO + pedestal_size_z=0, # TODO + ) + self.shaker = ShakingCapability(backend=self._backend) + self._capabilities = [self.shaker] + + +class BioShake3000ElmDWP(BioShake): + """BioShake 3000 elm DWP - shaking 200-3000 rpm, ELM, no heating.""" + + def __init__(self, name: str, port: str): + raise NotImplementedError("BioShake3000ElmDWP is missing resource definition.") + super().__init__( + name=name, + backend=BioShakeBackend(port=port), + size_x=0, + size_y=0, + size_z=0, # TODO + child_location=Coordinate(0, 0, 0), # TODO + pedestal_size_z=0, # TODO + ) + self.shaker = ShakingCapability(backend=self._backend) + self._capabilities = [self.shaker] + + +class BioShakeD30Elm(BioShake): + """BioShake D30 elm - shaking 200-2000 rpm, ELM, no heating.""" + + def __init__(self, name: str, port: str): + raise NotImplementedError("BioShakeD30Elm is missing resource definition.") + super().__init__( + name=name, + backend=BioShakeBackend(port=port), + size_x=0, + size_y=0, + size_z=0, # TODO + child_location=Coordinate(0, 0, 0), # TODO + pedestal_size_z=0, # TODO + ) + self.shaker = ShakingCapability(backend=self._backend) + self._capabilities = [self.shaker] + + +class BioShake5000Elm(BioShake): + """BioShake 5000 elm - shaking 200-5000 rpm, ELM, no heating.""" + + def __init__(self, name: str, port: str): + raise NotImplementedError("BioShake5000Elm is missing resource definition.") + super().__init__( + name=name, + backend=BioShakeBackend(port=port), + size_x=0, + size_y=0, + size_z=0, # TODO + child_location=Coordinate(0, 0, 0), # TODO + pedestal_size_z=0, # TODO + ) + self.shaker = ShakingCapability(backend=self._backend) + self._capabilities = [self.shaker] + + +# Shaking + heating (no active cooling) + + +class BioShake3000T(BioShake): + """BioShake 3000-T - shaking 200-3000 rpm, no ELM, heating.""" + + def __init__(self, name: str, port: str): + raise NotImplementedError("BioShake3000T is missing resource definition.") + super().__init__( + name=name, + backend=BioShakeBackend(port=port), + size_x=0, + size_y=0, + size_z=0, # TODO + child_location=Coordinate(0, 0, 0), # TODO + pedestal_size_z=0, # TODO + ) + self.tc = TemperatureControlCapability(backend=self._backend) + self.shaker = ShakingCapability(backend=self._backend) + self._capabilities = [self.tc, self.shaker] + + +class BioShake3000TElm(BioShake): + """BioShake 3000-T elm - shaking 200-3000 rpm, ELM, heating.""" + + def __init__(self, name: str, port: str): + raise NotImplementedError("BioShake3000TElm is missing resource definition.") + super().__init__( + name=name, + backend=BioShakeBackend(port=port), + size_x=0, + size_y=0, + size_z=0, # TODO + child_location=Coordinate(0, 0, 0), # TODO + pedestal_size_z=0, # TODO + ) + self.tc = TemperatureControlCapability(backend=self._backend) + self.shaker = ShakingCapability(backend=self._backend) + self._capabilities = [self.tc, self.shaker] + + +class BioShakeD30TElm(BioShake): + """BioShake D30-T elm - shaking 200-2000 rpm, ELM, heating.""" + + def __init__(self, name: str, port: str): + raise NotImplementedError("BioShakeD30TElm is missing resource definition.") + super().__init__( + name=name, + backend=BioShakeBackend(port=port), + size_x=0, + size_y=0, + size_z=0, # TODO + child_location=Coordinate(0, 0, 0), # TODO + pedestal_size_z=0, # TODO + ) + self.tc = TemperatureControlCapability(backend=self._backend) + self.shaker = ShakingCapability(backend=self._backend) + self._capabilities = [self.tc, self.shaker] + + +# Shaking + heating + active cooling + + +class BioShakeQ1(BioShake): + """BioShake Q1 - shaking 200-3000 rpm, ELM, heating, active cooling.""" + + def __init__(self, name: str, port: str): + raise NotImplementedError("BioShakeQ1 is missing resource definition.") + super().__init__( + name=name, + backend=BioShakeBackend(port=port, supports_active_cooling=True), + size_x=0, + size_y=0, + size_z=0, # TODO + child_location=Coordinate(0, 0, 0), # TODO + pedestal_size_z=0, # TODO + ) + self.tc = TemperatureControlCapability(backend=self._backend) + self.shaker = ShakingCapability(backend=self._backend) + self._capabilities = [self.tc, self.shaker] + + +class BioShakeQ2(BioShake): + """BioShake Q2 - shaking 200-3000 rpm, ELM, heating, active cooling.""" + + def __init__(self, name: str, port: str): + raise NotImplementedError("BioShakeQ2 is missing resource definition.") + super().__init__( + name=name, + backend=BioShakeBackend(port=port, supports_active_cooling=True), + size_x=0, + size_y=0, + size_z=0, # TODO + child_location=Coordinate(0, 0, 0), # TODO + pedestal_size_z=0, # TODO + ) + self.tc = TemperatureControlCapability(backend=self._backend) + self.shaker = ShakingCapability(backend=self._backend) + self._capabilities = [self.tc, self.shaker] + + +# Temperature only + + +class Heatplate(BioShake): + """Heatplate - no shaking, heating only.""" + + def __init__(self, name: str, port: str): + raise NotImplementedError("Heatplate is missing resource definition.") + super().__init__( + name=name, + backend=BioShakeBackend(port=port), + size_x=0, + size_y=0, + size_z=0, # TODO + child_location=Coordinate(0, 0, 0), # TODO + pedestal_size_z=0, # TODO + ) + self.tc = TemperatureControlCapability(backend=self._backend) + self._capabilities = [self.tc] + + +class ColdPlate(BioShake): + """ColdPlate - no shaking, heating, active cooling.""" + + def __init__(self, name: str, port: str): + raise NotImplementedError("ColdPlate is missing resource definition.") + super().__init__( + name=name, + backend=BioShakeBackend(port=port, supports_active_cooling=True), + size_x=0, + size_y=0, + size_z=0, # TODO + child_location=Coordinate(0, 0, 0), # TODO + pedestal_size_z=0, # TODO + ) + self.tc = TemperatureControlCapability(backend=self._backend) + self._capabilities = [self.tc] diff --git a/pylabrobot/resources/plate.py b/pylabrobot/resources/plate.py index 51923c65ed7..1ae0e33bf05 100644 --- a/pylabrobot/resources/plate.py +++ b/pylabrobot/resources/plate.py @@ -158,12 +158,16 @@ def get_well(self, identifier: Union[str, int, Tuple[int, int]]) -> "Well": return super().get_item(identifier) - def get_wells(self, identifier: Union[str, Sequence[int]]) -> List["Well"]: + def get_wells(self, identifier: Optional[Union[str, Sequence[int]]] = None) -> List["Well"]: """Get the wells with the given identifier. + If no identifier is given, all wells are returned. + See :meth:`~.get_items` for more information. """ + if identifier is None: + return super().get_items(list(range(self.num_items))) return super().get_items(identifier) def has_lid(self) -> bool: diff --git a/pylabrobot/scales/__init__.py b/pylabrobot/scales/__init__.py deleted file mode 100644 index 2958eb3e4d7..00000000000 --- a/pylabrobot/scales/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from pylabrobot.scales.mettler_toledo_backend import MettlerToledoWXS205SDU -from pylabrobot.scales.scale import Scale diff --git a/pylabrobot/sealing/sealer.py b/pylabrobot/sealing/sealer.py deleted file mode 100644 index 3b3c027ac8e..00000000000 --- a/pylabrobot/sealing/sealer.py +++ /dev/null @@ -1,28 +0,0 @@ -from pylabrobot.machines import Machine - -from .backend import SealerBackend - - -class Sealer(Machine): - """A microplate sealer""" - - def __init__(self, backend: SealerBackend): - super().__init__(backend=backend) - self.backend: SealerBackend = backend # fix type - - async def seal(self, temperature: int, duration: float): - return await self.backend.seal(temperature=temperature, duration=duration) - - async def open(self): - return await self.backend.open() - - async def close(self): - return await self.backend.close() - - async def set_temperature(self, temperature: float): - """Set the temperature of the sealer in degrees Celsius.""" - return await self.backend.set_temperature(temperature=temperature) - - async def get_temperature(self) -> float: - """Get the current temperature of the sealer in degrees Celsius.""" - return await self.backend.get_temperature() diff --git a/pylabrobot/shaking/shaker.py b/pylabrobot/shaking/shaker.py deleted file mode 100644 index a503279e5d4..00000000000 --- a/pylabrobot/shaking/shaker.py +++ /dev/null @@ -1,69 +0,0 @@ -import asyncio -from typing import Optional - -from pylabrobot.machines.machine import Machine -from pylabrobot.resources import Coordinate, ResourceHolder - -from .backend import ShakerBackend - - -class Shaker(ResourceHolder, Machine): - """A shaker machine""" - - def __init__( - self, - name: str, - size_x: float, - size_y: float, - size_z: float, - backend: ShakerBackend, - child_location: Coordinate, - category: str = "shaker", - model: Optional[str] = None, - ): - ResourceHolder.__init__( - self, - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - category=category, - model=model, - child_location=child_location, - ) - Machine.__init__(self, backend=backend) - self.backend: ShakerBackend = backend # fix type - - async def shake(self, speed: float, duration: Optional[float] = None, **backend_kwargs): - """Shake the shaker at the given speed - - Args: - speed: Speed of shaking in revolutions per minute (RPM) - duration: Duration of shaking in seconds. If None, shake indefinitely (and return immediately). - """ - if self.backend.supports_locking: - await self.backend.lock_plate() - await self.backend.start_shaking(speed=speed, **backend_kwargs) - - if duration is None: - return - - await asyncio.sleep(duration) - await self.backend.stop_shaking() - if self.backend.supports_locking: - await self.backend.unlock_plate() - - async def stop_shaking(self, **backend_kwargs): - await self.backend.stop_shaking(**backend_kwargs) - - async def lock_plate(self, **backend_kwargs): - await self.backend.lock_plate(**backend_kwargs) - - async def unlock_plate(self, **backend_kwargs): - await self.backend.unlock_plate(**backend_kwargs) - - def serialize(self) -> dict: - return { - **Machine.serialize(self), - **ResourceHolder.serialize(self), - } diff --git a/pylabrobot/storage/cytomat/__init__.py b/pylabrobot/storage/cytomat/__init__.py deleted file mode 100644 index 9a6315180ef..00000000000 --- a/pylabrobot/storage/cytomat/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .cytomat import CytomatBackend, CytomatChatterbox, CytomatType diff --git a/pylabrobot/temperature_controlling/inheco/cpac_backend.py b/pylabrobot/temperature_controlling/inheco/cpac_backend.py deleted file mode 100644 index 6a9f4715c8f..00000000000 --- a/pylabrobot/temperature_controlling/inheco/cpac_backend.py +++ /dev/null @@ -1,7 +0,0 @@ -from pylabrobot.temperature_controlling.inheco.temperature_controller import ( - InhecoTemperatureControllerBackend, -) - - -class InhecoCPACBackend(InhecoTemperatureControllerBackend): - pass diff --git a/pylabrobot/temperature_controlling/inheco/temperature_controller.py b/pylabrobot/temperature_controlling/inheco/temperature_controller.py deleted file mode 100644 index 6c78abb5a40..00000000000 --- a/pylabrobot/temperature_controlling/inheco/temperature_controller.py +++ /dev/null @@ -1,83 +0,0 @@ -import abc -import warnings - -from pylabrobot.temperature_controlling.backend import TemperatureControllerBackend -from pylabrobot.temperature_controlling.inheco.control_box import InhecoTECControlBox - - -class InhecoTemperatureControllerBackend(TemperatureControllerBackend, metaclass=abc.ABCMeta): - """Universal backend for Inheco Temperature Controller devices such as ThermoShake and CPAC""" - - @property - def supports_active_cooling(self) -> bool: - return True - - def __init__(self, index: int, control_box: InhecoTECControlBox): - assert 1 <= index <= 6, "Index must be between 1 and 6 (inclusive)" - self.index = index - self.interface = control_box - - async def setup(self): - pass - - async def stop(self): - await self.stop_temperature_control() - - def serialize(self) -> dict: - warnings.warn("The interface is not serialized.") - return super().serialize() - - # -- temperature control - - async def set_temperature(self, temperature: float): - await self.set_target_temperature(temperature) - await self.start_temperature_control() - - async def get_current_temperature(self) -> float: - response = await self.interface.send_command(f"{self.index}RAT0") - return float(response) / 10 - - async def deactivate(self): - await self.stop_temperature_control() - - # --- firmware temp - - async def set_target_temperature(self, temperature: float): - temperature = int(temperature * 10) - await self.interface.send_command(f"{self.index}STT{temperature}") - - async def start_temperature_control(self): - """Start the temperature control""" - - return await self.interface.send_command(f"{self.index}ATE1") - - async def stop_temperature_control(self): - """Stop the temperature control""" - - return await self.interface.send_command(f"{self.index}ATE0") - - # --- firmware misc - - async def get_device_info(self, info_type: int): - """Get device information - - - 0 Bootstrap Version - - 1 Application Version - - 2 Serial number - - 3 Current hardware version - - 4 INHECO copyright - """ - - assert info_type in range(5), "Info type must be in the range 0 to 4" - return await self.interface.send_command(f"{self.index}RFV{info_type}") - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class InhecoThermoShake: - def __init__(self, *args, **kwargs): - raise RuntimeError( - "`InhecoThermoShake` is deprecated. Please use `InhecoThermoShakeBackend` instead. " - ) diff --git a/pylabrobot/thermo_fisher/__init__.py b/pylabrobot/thermo_fisher/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/thermo_fisher/cytomat/__init__.py b/pylabrobot/thermo_fisher/cytomat/__init__.py new file mode 100644 index 00000000000..af6cb483a59 --- /dev/null +++ b/pylabrobot/thermo_fisher/cytomat/__init__.py @@ -0,0 +1,5 @@ +from .backend import CytomatBackend +from .chatterbox import CytomatChatterbox +from .constants import CytomatType +from .cytomat import Cytomat +from .heraeus_backend import HeraeusCytomatBackend diff --git a/pylabrobot/storage/cytomat/cytomat.py b/pylabrobot/thermo_fisher/cytomat/backend.py similarity index 78% rename from pylabrobot/storage/cytomat/cytomat.py rename to pylabrobot/thermo_fisher/cytomat/backend.py index ca9227fe003..c2b81fa03fa 100644 --- a/pylabrobot/storage/cytomat/cytomat.py +++ b/pylabrobot/thermo_fisher/cytomat/backend.py @@ -12,10 +12,14 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e +from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend +from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend +from pylabrobot.capabilities.shaking.backend import ShakerBackend +from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.device import DeviceBackend from pylabrobot.io.serial import Serial from pylabrobot.resources import Plate, PlateCarrier, PlateHolder -from pylabrobot.storage.backend import IncubatorBackend -from pylabrobot.storage.cytomat.constants import ( +from pylabrobot.thermo_fisher.cytomat.constants import ( ActionRegister, ActionType, CytomatActionResponse, @@ -28,20 +32,20 @@ SwapStationPosition, WarningRegister, ) -from pylabrobot.storage.cytomat.errors import ( +from pylabrobot.thermo_fisher.cytomat.errors import ( CytomatBusyError, CytomatCommandUnknownError, CytomatTelegramStructureError, error_map, error_register_map, ) -from pylabrobot.storage.cytomat.schemas import ( +from pylabrobot.thermo_fisher.cytomat.schemas import ( ActionRegisterState, OverviewRegisterState, SensorStates, SwapStationState, ) -from pylabrobot.storage.cytomat.utils import ( +from pylabrobot.thermo_fisher.cytomat.utils import ( hex_to_base_twelve, hex_to_binary, validate_storage_location_number, @@ -50,7 +54,12 @@ logger = logging.getLogger(__name__) -class CytomatBackend(IncubatorBackend): +class CytomatBackend( + AutomatedRetrievalBackend, + TemperatureControllerBackend, + HumidityControllerBackend, + ShakerBackend, +): default_baud = 9600 serial_message_encoding = "utf-8" @@ -82,6 +91,7 @@ def __init__(self, model: Union[CytomatType, str], port: str): self._racks: List[PlateCarrier] = [] self.io = Serial( + human_readable_device_name="Cytomat", port=port, baudrate=self.default_baud, bytesize=serial.EIGHTBITS, @@ -89,20 +99,83 @@ def __init__(self, model: Union[CytomatType, str], port: str): stopbits=serial.STOPBITS_ONE, write_timeout=1, timeout=1, - human_readable_device_name="Cytomat", ) async def setup(self): + await DeviceBackend.setup(self) await self.io.setup() await self.initialize() await self.wait_for_task_completion() + async def stop(self): + await self.io.stop() + await DeviceBackend.stop(self) + async def set_racks(self, racks: List[PlateCarrier]): - await super().set_racks(racks) + self._racks = racks warnings.warn("Cytomat racks need to be configured with the exe software") - async def stop(self): - await self.io.stop() + # -- AutomatedRetrievalBackend -- + + async def fetch_plate_to_loading_tray(self, plate: Plate): + site = plate.parent + assert isinstance(site, PlateHolder) + await self.action_storage_to_transfer(site) + + async def store_plate(self, plate: Plate, site: PlateHolder): + await self.action_transfer_to_storage(site) + + # -- TemperatureControllerBackend -- + + @property + def supports_active_cooling(self) -> bool: + return False + + async def set_temperature(self, temperature: float): + raise NotImplementedError("Temperature is configured via the Cytomat device UI") + + async def get_current_temperature(self) -> float: + return (await self.get_incubation_query("it")).actual_value + + async def deactivate(self): + pass # no-op: temperature is device-managed + + # -- HumidityControllerBackend -- + + @property + def supports_humidity_control(self) -> bool: + return False # read-only + + async def set_humidity(self, humidity: float): + raise NotImplementedError("Humidity is configured via the Cytomat device UI") + + async def get_current_humidity(self) -> float: + return (await self.get_incubation_query("ih")).actual_value + + # -- ShakerBackend -- + + @property + def supports_locking(self) -> bool: + return False + + async def lock_plate(self): + raise NotImplementedError("Cytomat does not support plate locking") + + async def unlock_plate(self): + raise NotImplementedError("Cytomat does not support plate locking") + + async def start_shaking(self, speed: float, shakers: Optional[List[int]] = None): + if self.model == CytomatType.C5C: + raise NotImplementedError("Shaking is not supported on this model") + await self.set_shaking_frequency(frequency=int(speed), shakers=shakers) + return hex_to_binary(await self.send_command("ll", "va", "")) + + async def stop_shaking(self): + if self.model == CytomatType.C5C: + raise NotImplementedError("Shaking is not supported on this model") + return hex_to_binary(await self.send_command("ll", "vd", "")) + + # -- Device-specific methods -- def _assemble_command(self, command_type: str, command: str, params: str): carriage_return = "\r" if self.model == CytomatType.C2C_425 else "\r\n" @@ -120,7 +193,6 @@ async def _send_command(command_str) -> str: value = " ".join(values) if key == CytomatActionResponse.OK.value or key == command: - # actions return an OK response, while checks return the command at the start of the response return value if key == CytomatActionResponse.ERROR.value: logger.error("Command %s failed with: '%s'", command_str, resp) @@ -138,9 +210,6 @@ async def _send_command(command_str) -> str: await self.reset_error_register() raise Exception(f"Unknown response from cytomat: {resp}") - # Cytomats sometimes return a busy or command not recognized error even when the overview - # register says the machine is not busy, or if the command is known. We will retry a few times, - # which costs 1s if there is a true error, but is necessary to avoid false negatives. command_str = self._assemble_command(command_type=command_type, command=command, params=params) n_retries = 10 exc: Optional[BaseException] = None @@ -158,11 +227,6 @@ async def _send_command(command_str) -> str: async def send_action( self, command_type: str, command: str, params: str, timeout: Optional[int] = 60 ) -> OverviewRegisterState: - """Calls send_command, but has a timeout handler and returns the overview register state. - Args: - timeout: The maximum time to wait for the command to complete. If None, the command will not - wait for completion. - """ await self.send_command(command_type, command, params) if timeout is not None: overview_register = await self.wait_for_task_completion(timeout=timeout) @@ -170,15 +234,12 @@ async def send_action( def _site_to_firmware_string(self, site: PlateHolder) -> str: rack = cast(PlateCarrier, site.parent) - rack_idx = [rack.name for rack in self._racks].index( - rack.name - ) # autoreload resistant, should work + rack_idx = [rack.name for rack in self._racks].index(rack.name) site_idx = next(idx for idx, s in rack.sites.items() if s == site) if self.model in [CytomatType.C2C_425]: return f"{str(rack_idx).zfill(2)} {str(site_idx).zfill(2)}" - # TODO: configure all cytomats to use `rack site` format if self.model in [ CytomatType.C6000, CytomatType.C6002, @@ -187,15 +248,11 @@ def _site_to_firmware_string(self, site: PlateHolder) -> str: ]: slots_to_skip = sum(r.capacity for r in self._racks[:rack_idx]) absolute_slot = slots_to_skip + site_idx + 1 # 1-indexed - return f"{absolute_slot:03}" raise ValueError(f"Unsupported Cytomat model: {self.model}") async def get_overview_register(self) -> OverviewRegisterState: - # Sometimes this command is not recognized and it is not known why. We will retry a few times - # We don't care if the cytomat is still busy, that is actually what we are often checking for. - # We are just gathering state, so just try a little bit later. num_tries = 10 for _ in range(num_tries): try: @@ -212,7 +269,6 @@ async def get_warning_register(self) -> WarningRegister: for member in WarningRegister: if hex_value == member.value: return member - await self.reset_error_register() raise Exception(f"Unknown warning register value: {hex_value}") @@ -221,7 +277,6 @@ async def get_error_register(self) -> ErrorRegister: for member in ErrorRegister: if hex_value == member.value: return member - await self.reset_error_register() raise Exception(f"Unknown error register value: {hex_value}") @@ -229,7 +284,7 @@ async def reset_error_register(self) -> None: await self.send_command("rs", "be", "") async def initialize(self) -> None: - await self.send_action("ll", "in", "", timeout=300) # this command sometimes times out + await self.send_action("ll", "in", "", timeout=300) async def open_door(self): return await self.send_action("ll", "gp", "002") @@ -279,15 +334,11 @@ async def get_sensor_register(self) -> SensorStates: **{member.name: bool(int(binary_values[member.value])) for member in SensorRegister} ) - async def action_transfer_to_storage( # used by insert_plate - self, site: PlateHolder - ) -> OverviewRegisterState: + async def action_transfer_to_storage(self, site: PlateHolder) -> OverviewRegisterState: """Open lift door, retrieve from transfer, close door, place at storage""" return await self.send_action("mv", "ts", self._site_to_firmware_string(site), timeout=120) - async def action_storage_to_transfer( # used by retrieve_plate - self, site: PlateHolder - ) -> OverviewRegisterState: + async def action_storage_to_transfer(self, site: PlateHolder) -> OverviewRegisterState: """Retrieve from storage, open door, move to transfer, close door""" return await self.send_action("mv", "st", self._site_to_firmware_string(site)) @@ -328,7 +379,6 @@ async def action_read_barcode( site_number_a: str, site_number_b: str, ) -> OverviewRegisterState: - # Read barcode of storage locations validate_storage_location_number(site_number_a) validate_storage_location_number(site_number_b) resp = await self.send_command("mv", "sn", f"{site_number_a} {site_number_b}") @@ -340,18 +390,10 @@ async def wait_for_transfer_station(self, occupied: bool = False): await asyncio.sleep(1) async def wait_for_task_completion(self, timeout=60) -> OverviewRegisterState: - """ - Wait for the cytomat to finish the current task. This is done by checking the overview register - until the busy bit is not set. If the cytomat is busy for too long, a TimeoutError is raised. - If the error bit is set in the overview register, the error register is read and the corresponding - error is raised. - """ start = time.time() while True: overview_register = await self.get_overview_register() if not overview_register.busy_bit_set: - # only check for errors once the cytomat is done, so that the user has the chance to - # handle the error and proceed if desired. if overview_register.error_register_set: error_register = await self.get_error_register() await self.reset_error_register() @@ -364,17 +406,6 @@ async def wait_for_task_completion(self, timeout=60) -> OverviewRegisterState: async def init_shakers(self): return hex_to_binary(await self.send_command("ll", "vi", "")) - async def start_shaking(self, frequency: float, shakers: Optional[List[int]] = None): - if self.model == CytomatType.C5C: - raise NotImplementedError("Shaking is not supported on this model") - await self.set_shaking_frequency(frequency=int(frequency), shakers=shakers) - return hex_to_binary(await self.send_command("ll", "va", "")) - - async def stop_shaking(self): - if self.model == CytomatType.C5C: - raise NotImplementedError("Shaking is not supported on this model") - return hex_to_binary(await self.send_command("ll", "vd", "")) - async def set_shaking_frequency( self, frequency: int, shakers: Optional[List[int]] = None ) -> List[str]: @@ -403,49 +434,9 @@ async def get_o2(self) -> CytomatIncupationResponse: async def get_temperature(self) -> float: return (await self.get_incubation_query("it")).actual_value - async def fetch_plate_to_loading_tray(self, plate: Plate, **backend_kwargs): - site = plate.parent - assert isinstance(site, PlateHolder) - await self.action_storage_to_transfer(site) - - async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs): - await self.action_transfer_to_storage(site) - - async def set_temperature(self, *args, **kwargs): - raise NotImplementedError("Temperature control is not implemented yet") - def serialize(self) -> dict: return { - **IncubatorBackend.serialize(self), + **DeviceBackend.serialize(self), "model": self.model.value, "port": self.io.port, } - - -class CytomatChatterbox(CytomatBackend): - async def setup(self): - await self.wait_for_task_completion() - - async def stop(self): - print("closing connection to cytomat") - - async def send_command(self, command_type, command, params): - print( - "cytomat", self._assemble_command(command_type=command_type, command=command, params=params) - ) - if command_type == "ch": - return "0" - return "0" * 8 - - async def wait_for_transfer_station(self, occupied: bool = False): - # send the command, but don't wait when we are in chatting mode. - _ = await self.get_overview_register() - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 - - -class Cytomat: - def __init__(self, *args, **kwargs): - raise RuntimeError("`Cytomat` is deprecated. Please use `CytomatBackend` instead. ") diff --git a/pylabrobot/thermo_fisher/cytomat/chatterbox.py b/pylabrobot/thermo_fisher/cytomat/chatterbox.py new file mode 100644 index 00000000000..4eaf49f6f3b --- /dev/null +++ b/pylabrobot/thermo_fisher/cytomat/chatterbox.py @@ -0,0 +1,20 @@ +from pylabrobot.thermo_fisher.cytomat.backend import CytomatBackend + + +class CytomatChatterbox(CytomatBackend): + async def setup(self): + await self.wait_for_task_completion() + + async def stop(self): + print("closing connection to cytomat") + + async def send_command(self, command_type, command, params): + print( + "cytomat", self._assemble_command(command_type=command_type, command=command, params=params) + ) + if command_type == "ch": + return "0" + return "0" * 8 + + async def wait_for_transfer_station(self, occupied: bool = False): + _ = await self.get_overview_register() diff --git a/pylabrobot/storage/cytomat/constants.py b/pylabrobot/thermo_fisher/cytomat/constants.py similarity index 100% rename from pylabrobot/storage/cytomat/constants.py rename to pylabrobot/thermo_fisher/cytomat/constants.py diff --git a/pylabrobot/thermo_fisher/cytomat/cytomat.py b/pylabrobot/thermo_fisher/cytomat/cytomat.py new file mode 100644 index 00000000000..de53a2bf7f6 --- /dev/null +++ b/pylabrobot/thermo_fisher/cytomat/cytomat.py @@ -0,0 +1,193 @@ +import random +from typing import List, Literal, Optional, Union, cast + +from pylabrobot.capabilities.automated_retrieval import AutomatedRetrievalCapability +from pylabrobot.capabilities.humidity_controlling import HumidityControlCapability +from pylabrobot.capabilities.shaking import ShakingCapability +from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.device import Device +from pylabrobot.resources import ( + Coordinate, + Plate, + PlateCarrier, + PlateHolder, + Resource, + ResourceNotFoundError, + Rotation, +) + +from .backend import CytomatBackend +from .constants import CytomatType + + +class NoFreeSiteError(Exception): + pass + + +class Cytomat(Resource, Device): + _racks: List[PlateCarrier] + _backend: CytomatBackend + loading_tray: PlateHolder + retrieval: AutomatedRetrievalCapability + tc: TemperatureControlCapability + humidity: HumidityControlCapability + shaker: ShakingCapability + + def __init__( + self, + name: str, + backend: CytomatBackend, + racks: List[PlateCarrier], + loading_tray_location: Coordinate, + size_x: float = 0, + size_y: float = 0, + size_z: float = 0, + rotation: Optional[Rotation] = None, + category: Optional[str] = None, + model: Optional[str] = None, + ): + raise NotImplementedError("Cytomat resource definition is not verified.") + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + rotation=rotation, + category=category, + model=model, + ) + Device.__init__(self, backend=backend) + self._backend: CytomatBackend = backend + + self.loading_tray = PlateHolder( + name=f"{name}_tray", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0 + ) + self.assign_child_resource(self.loading_tray, location=loading_tray_location) + + self._racks = racks + for rack in self._racks: + self.assign_child_resource(rack, location=None) + + self.retrieval = AutomatedRetrievalCapability(backend=backend) + self.tc = TemperatureControlCapability(backend=backend) + self.humidity = HumidityControlCapability(backend=backend) + + caps = [self.tc, self.humidity, self.retrieval] + + if backend.model != CytomatType.C5C: + self.shaker = ShakingCapability(backend=backend) + caps.append(self.shaker) + + self._capabilities = caps + + @property + def racks(self) -> List[PlateCarrier]: + return self._racks + + async def setup(self, **backend_kwargs): + await super().setup() + await self._backend.set_racks(self._racks) + + def get_num_free_sites(self) -> int: + return sum([len(rack.get_free_sites()) for rack in self._racks]) + + def get_site_by_plate_name(self, plate_name: str) -> PlateHolder: + for rack in self._racks: + for site in rack.sites.values(): + if site.resource is not None and site.resource.name == plate_name: + return site + raise ResourceNotFoundError(f"Plate {plate_name} not found in '{self.name}'") + + async def fetch_plate_to_loading_tray(self, plate_name: str) -> Plate: + """Fetch a plate from storage and put it on the loading tray.""" + site = self.get_site_by_plate_name(plate_name) + plate = site.resource + assert plate is not None + await self.retrieval.fetch_plate_to_loading_tray(plate) + plate.unassign() + self.loading_tray.assign_child_resource(plate) + return plate + + def _find_available_sites_sorted(self, plate: Plate) -> List[PlateHolder]: + def _plate_height(p: Plate): + if p.has_lid(): + return p.get_size_z() + 3 + return p.get_size_z() + + available = [ + site + for rack in self._racks + for site in rack.get_free_sites() + if site.get_size_z() >= _plate_height(plate) + ] + if len(available) == 0: + raise NoFreeSiteError(f"No free site found in '{self.name}' for plate '{plate.name}'") + return sorted(available, key=lambda site: site.get_size_z()) + + def find_smallest_site_for_plate(self, plate: Plate) -> PlateHolder: + return self._find_available_sites_sorted(plate)[0] + + def find_random_site(self, plate: Plate) -> PlateHolder: + return random.choice(self._find_available_sites_sorted(plate)) + + async def take_in_plate(self, site: Union[PlateHolder, Literal["random", "smallest"]]): + """Take a plate from the loading tray and put it in storage.""" + plate = cast(Plate, self.loading_tray.resource) + if plate is None: + raise ResourceNotFoundError(f"No plate on the loading tray of '{self.name}'") + + if site == "random": + site = self.find_random_site(plate) + elif site == "smallest": + site = self.find_smallest_site_for_plate(plate) + elif isinstance(site, PlateHolder): + if site not in self._find_available_sites_sorted(plate): + raise ValueError(f"Site {site.name} is not available for plate {plate.name}") + else: + raise ValueError(f"Invalid site: {site}") + await self.retrieval.store_plate(plate, site) + plate.unassign() + site.assign_child_resource(plate) + + def summary(self) -> str: + def create_pretty_table(header, *columns) -> str: + col_widths = [ + max(len(str(item)) for item in [header[i]] + list(columns[i])) for i in range(len(header)) + ] + + def format_row(row, border="|") -> str: + return ( + f"{border} " + + " | ".join(f"{str(row[i]).ljust(col_widths[i])}" for i in range(len(row))) + + f" {border}" + ) + + def separator_line(cross: str = "+", line: str = "-") -> str: + return cross + cross.join(line * (width + 2) for width in col_widths) + cross + + table = [] + table.append(separator_line()) + table.append(format_row(header)) + table.append(separator_line()) + for row in zip(*columns): + table.append(format_row(row)) + table.append(separator_line()) + return "\n".join(table) + + header = [f"Rack {i}" for i in range(len(self._racks))] + sites = [ + [site.resource.name if site.resource else "" for site in reversed(rack.sites.values())] + for rack in self._racks + ] + return create_pretty_table(header, *sites) + + def serialize(self): + from pylabrobot.serializer import serialize + + return { + **Device.serialize(self), + **Resource.serialize(self), + "racks": [rack.serialize() for rack in self._racks], + "loading_tray_location": serialize(self.loading_tray.location), + } diff --git a/pylabrobot/storage/cytomat/errors.py b/pylabrobot/thermo_fisher/cytomat/errors.py similarity index 98% rename from pylabrobot/storage/cytomat/errors.py rename to pylabrobot/thermo_fisher/cytomat/errors.py index 2f493349a6a..ef3955bf845 100644 --- a/pylabrobot/storage/cytomat/errors.py +++ b/pylabrobot/thermo_fisher/cytomat/errors.py @@ -1,6 +1,6 @@ from typing import Dict -from pylabrobot.storage.cytomat.constants import ErrorRegister +from pylabrobot.thermo_fisher.cytomat.constants import ErrorRegister class CytomatBusyError(Exception): diff --git a/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py similarity index 68% rename from pylabrobot/storage/cytomat/heraeus_cytomat_backend.py rename to pylabrobot/thermo_fisher/cytomat/heraeus_backend.py index a991d55fc59..8890cc4ca8e 100644 --- a/pylabrobot/storage/cytomat/heraeus_cytomat_backend.py +++ b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py @@ -12,15 +12,24 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e +from pylabrobot.capabilities.automated_retrieval.backend import AutomatedRetrievalBackend +from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend +from pylabrobot.capabilities.shaking.backend import ShakerBackend +from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.device import DeviceBackend from pylabrobot.io.serial import Serial from pylabrobot.resources import Plate, PlateHolder from pylabrobot.resources.carrier import PlateCarrier -from pylabrobot.storage.backend import IncubatorBackend logger = logging.getLogger(__name__) -class HeraeusCytomatBackend(IncubatorBackend): +class HeraeusCytomatBackend( + AutomatedRetrievalBackend, + TemperatureControllerBackend, + HumidityControllerBackend, + ShakerBackend, +): """ Backend for legacy (Heraeus) Cytomats. Perhaps identical to Liconic backend... @@ -43,7 +52,9 @@ def __init__(self, port: str): f"Import error: {_SERIAL_IMPORT_ERROR}" ) super().__init__() + self._racks: List[PlateCarrier] = [] self.io = Serial( + human_readable_device_name="Heraeus Cytomat", port=port, baudrate=self.default_baud, bytesize=serial.EIGHTBITS, @@ -52,23 +63,16 @@ def __init__(self, port: str): write_timeout=1, timeout=1, rtscts=True, - human_readable_device_name="Heraeus Cytomat", ) - async def setup(self) -> Serial: - """ - 1. Open serial port (9600 8E1, RTS/CTS) via the Serial wrapper. - 2. Send >200 ms break, wait 150 ms, flush buffers. - 3. Handshake: CR → wait for CC - 4. Activate handling: ST 1801 → expect OK - 5. Poll ready-flag: RD 1915 → wait for "1" - """ + async def setup(self): + await DeviceBackend.setup(self) try: await self.io.setup() except serial.SerialException as e: raise RuntimeError(f"Could not open {self.io.port}: {e}") - await self.io.send_break(duration=0.2) # >100 ms required + await self.io.send_break(duration=0.2) await asyncio.sleep(0.15) await self.io.reset_input_buffer() await self.io.reset_output_buffer() @@ -76,7 +80,7 @@ async def setup(self) -> Serial: await self.io.write(b"CR\r") deadline = time.time() + self.init_timeout while time.time() < deadline: - resp = await self.io.readline() # reads through LF + resp = await self.io.readline() if resp.strip() == b"CC": break else: @@ -94,7 +98,7 @@ async def setup(self) -> Serial: await self.io.write(b"RD 1915\r") flag = await self.io.readline() if flag.strip() == b"1": - return self.io + return await asyncio.sleep(self.poll_interval) await self.io.stop() @@ -102,51 +106,72 @@ async def setup(self) -> Serial: async def stop(self): await self.io.stop() + await DeviceBackend.stop(self) async def set_racks(self, racks: List[PlateCarrier]): - await super().set_racks(racks) + self._racks = racks warnings.warn("Cytomat racks need to be configured manually on each setup") - async def initialize(self): - await self._send_command("ST 1900") - await self._send_command("ST 1801") - await self._wait_ready() - - async def open_door(self): - await self._send_command("ST 1901") - await self._wait_ready() - - async def close_door(self): - await self._send_command("ST 1902") - await self._wait_ready() + # -- AutomatedRetrievalBackend -- - async def fetch_plate_to_loading_tray(self, plate: Plate, **backend_kwargs): - """Fetch a plate from storage onto the transfer station, with gate open/close.""" + async def fetch_plate_to_loading_tray(self, plate: Plate): site = plate.parent assert isinstance(site, PlateHolder), "Plate not in storage" m, n = self._site_to_m_n(site) - await self._send_command(f"WR DM0 {m}") # carousel pos - await self._send_command(f"WR DM5 {n}") # handler level - await self._send_command("ST 1905") # plate to transfer station + await self._send_command(f"WR DM0 {m}") + await self._send_command(f"WR DM5 {n}") + await self._send_command("ST 1905") await self._wait_ready() - await self._send_command("ST 1903") # terminate access + await self._send_command("ST 1903") - async def take_in_plate(self, plate: Plate, site: PlateHolder, **backend_kwargs): - """Place a plate from the transfer station into storage at the given site.""" + async def store_plate(self, plate: Plate, site: PlateHolder): m, n = self._site_to_m_n(site) - await self._send_command(f"WR DM0 {m}") # carousel pos - await self._send_command(f"WR DM5 {n}") # handler level - await self._send_command("ST 1904") # plate to storage + await self._send_command(f"WR DM0 {m}") + await self._send_command(f"WR DM5 {n}") + await self._send_command("ST 1904") await self._wait_ready() - await self._send_command("ST 1903") # terminate access + await self._send_command("ST 1903") + + # -- TemperatureControllerBackend -- + + @property + def supports_active_cooling(self) -> bool: + return False async def set_temperature(self, temperature: float): raise NotImplementedError("Temperature control not implemented yet") - async def get_temperature(self) -> float: + async def get_current_temperature(self) -> float: raise NotImplementedError("Temperature query not implemented yet") - async def start_shaking(self, frequency: float = 1.0): + async def deactivate(self): + pass + + # -- HumidityControllerBackend -- + + @property + def supports_humidity_control(self) -> bool: + return False + + async def set_humidity(self, humidity: float): + raise NotImplementedError("Humidity control not implemented yet") + + async def get_current_humidity(self) -> float: + raise NotImplementedError("Humidity query not implemented yet") + + # -- ShakerBackend -- + + @property + def supports_locking(self) -> bool: + return False + + async def lock_plate(self): + raise NotImplementedError("Heraeus Cytomat does not support plate locking") + + async def unlock_plate(self): + raise NotImplementedError("Heraeus Cytomat does not support plate locking") + + async def start_shaking(self, speed: float): await self._send_command("ST 1607") await self._wait_ready() @@ -154,18 +179,17 @@ async def stop_shaking(self): await self._send_command("RS 1607") await self._wait_ready() + # -- Device-specific methods -- + def _site_to_m_n(self, site: PlateHolder) -> Tuple[int, int]: rack = site.parent assert isinstance(rack, PlateCarrier), "Site not in rack" assert self._racks is not None, "Racks not set" - rack_idx = self._racks.index(rack) + 1 # plr is 0-indexed, cytomat is 1-indexed - site_idx = next(idx for idx, s in rack.sites.items() if s == site) + 1 # 1-indexed + rack_idx = self._racks.index(rack) + 1 + site_idx = next(idx for idx, s in rack.sites.items() if s == site) + 1 return rack_idx, site_idx async def _send_command(self, command: str) -> str: - """ - Send an ASCII command (without CR) and return the raw response string. - """ cmd = command.strip() + "\r" logger.debug("Sending Cytomat command: %r", cmd) await self.io.write(cmd.encode(self.serial_message_encoding)) @@ -182,14 +206,10 @@ async def wait_for_transfer_station(self, occupied: bool = False): await asyncio.sleep(1) async def read_plate_detection_xfer(self) -> bool: - """Read Plate Detection Transfer Station (RD 1813).""" resp = await self._send_command("RD 1813") return resp == "1" async def _wait_ready(self, timeout: int = 60): - """ - Poll the ready flag (RD 1915) until it becomes '1' or timeout. - """ start = time.time() while True: resp = await self._send_command("RD 1915") @@ -199,9 +219,22 @@ async def _wait_ready(self, timeout: int = 60): if time.time() - start > timeout: raise TimeoutError("Legacy Cytomat did not become ready in time") + async def initialize(self): + await self._send_command("ST 1900") + await self._send_command("ST 1801") + await self._wait_ready() + + async def open_door(self): + await self._send_command("ST 1901") + await self._wait_ready() + + async def close_door(self): + await self._send_command("ST 1902") + await self._wait_ready() + def serialize(self) -> dict: return { - **super().serialize(), + **DeviceBackend.serialize(self), "port": self.io.port, } diff --git a/pylabrobot/storage/cytomat/racks.py b/pylabrobot/thermo_fisher/cytomat/racks.py similarity index 100% rename from pylabrobot/storage/cytomat/racks.py rename to pylabrobot/thermo_fisher/cytomat/racks.py diff --git a/pylabrobot/storage/cytomat/schemas.py b/pylabrobot/thermo_fisher/cytomat/schemas.py similarity index 92% rename from pylabrobot/storage/cytomat/schemas.py rename to pylabrobot/thermo_fisher/cytomat/schemas.py index b32f85c34ac..b6831fcebaa 100644 --- a/pylabrobot/storage/cytomat/schemas.py +++ b/pylabrobot/thermo_fisher/cytomat/schemas.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from pylabrobot.storage.cytomat.constants import ( +from pylabrobot.thermo_fisher.cytomat.constants import ( ActionRegister, ActionType, LoadStatusAtProcessor, @@ -8,7 +8,7 @@ OverviewRegister, SwapStationPosition, ) -from pylabrobot.storage.cytomat.utils import hex_to_binary +from pylabrobot.thermo_fisher.cytomat.utils import hex_to_binary @dataclass(frozen=True) diff --git a/pylabrobot/storage/cytomat/utils.py b/pylabrobot/thermo_fisher/cytomat/utils.py similarity index 100% rename from pylabrobot/storage/cytomat/utils.py rename to pylabrobot/thermo_fisher/cytomat/utils.py diff --git a/pylabrobot/tilting/chatterbox.py b/pylabrobot/tilting/chatterbox.py deleted file mode 100644 index 5ef3ea93be0..00000000000 --- a/pylabrobot/tilting/chatterbox.py +++ /dev/null @@ -1,12 +0,0 @@ -from pylabrobot.tilting import TilterBackend - - -class TilterChatterboxBackend(TilterBackend): - async def setup(self): - print("Setting up tilter.") - - async def stop(self): - print("Stopping tilter.") - - async def set_angle(self, angle: float): - print(f"Setting the angle to {angle}.") diff --git a/pylabrobot/tilting/hamilton.py b/pylabrobot/tilting/hamilton.py deleted file mode 100644 index d4233407c7b..00000000000 --- a/pylabrobot/tilting/hamilton.py +++ /dev/null @@ -1,46 +0,0 @@ -from pylabrobot.resources.coordinate import Coordinate -from pylabrobot.tilting.hamilton_backend import ( - HamiltonTiltModuleBackend, -) -from pylabrobot.tilting.tilter import Tilter - - -class HamiltonTiltModule(Tilter): - """A Hamilton tilt module.""" - - def __init__( - self, - name: str, - com_port: str, - child_location: Coordinate = Coordinate(1.0, 3.0, 83.55), - pedestal_size_z: float = 3.47, - write_timeout: float = 3, - timeout: float = 3, - ): - """Initialize a Hamilton tilt module. - - Args: - com_port: The communication port. - child_location: The location of the child resource. - pedestal_size_z: The size of the pedestal in the z dimension. - write_timeout: The write timeout. Defaults to 3. - timeout: The timeout. Defaults to 3. - """ - - super().__init__( - name=name, - size_x=132, - size_y=92.57, - size_z=85.81, - backend=HamiltonTiltModuleBackend( - com_port=com_port, - write_timeout=write_timeout, - timeout=timeout, - ), - hinge_coordinate=Coordinate(6.18, 0, 72.85), - child_location=child_location, - category="tilter", - model=HamiltonTiltModule.__name__, - ) - - self.pedestal_size_z = pedestal_size_z diff --git a/pyproject.toml b/pyproject.toml index a1225c3cd47..bee928744e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,9 +19,9 @@ hid = ["hid"] modbus = ["pymodbus>=3.0.0,<3.7.0"] opentrons = ["opentrons-http-api-client==0.2.0"] sila = ["zeroconf>=0.131.0", "grpcio"] -microscopy = ["numpy>=1.26", "opencv-python"] -pico = ["PyLabRobot[microscopy,sila]"] -all = ["PyLabRobot[serial,usb,ftdi,hid,modbus,websockets,visualizer,opentrons,sila]"] +cytation-microscopy = ["numpy>=1.26", "opencv-python"] +pico = ["PyLabRobot[sila]", "opencv-python", "numpy"] +all = ["PyLabRobot[serial,usb,ftdi,hid,modbus,websockets,visualizer,opentrons,sila,pico]"] test = [ "pytest", "pytest-timeout", From 94b1d0c1d0ef73f0a3f71bd4cdcbd7575246506c Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 14:08:24 -0700 Subject: [PATCH 02/69] Replace backend_kwargs with backend_params: SerializableMixin (#954) Co-authored-by: Claude Opus 4.6 (1M context) --- pylabrobot/agilent/biotek/biotek.py | 23 ++- pylabrobot/agilent/biotek/biotek_tests.py | 6 +- pylabrobot/agilent/biotek/cytation.py | 30 +++- pylabrobot/azenta/xpeel.py | 26 ++-- pylabrobot/bmg_labtech/clariostar.py | 24 ++- pylabrobot/byonoy/absorbance_96.py | 7 +- pylabrobot/byonoy/luminescence_96.py | 18 ++- pylabrobot/capabilities/microscopy/backend.py | 3 + .../capabilities/microscopy/chatterbox.py | 4 + .../capabilities/microscopy/microscopy.py | 21 +-- .../microscopy/microscopy_tests.py | 4 +- pylabrobot/capabilities/peeling/backend.py | 6 +- pylabrobot/capabilities/peeling/peeling.py | 11 +- .../plate_reading/absorbance/absorbance.py | 7 +- .../absorbance/absorbance_tests.py | 7 +- .../plate_reading/absorbance/backend.py | 9 +- .../plate_reading/absorbance/chatterbox.py | 7 +- .../plate_reading/fluorescence/backend.py | 4 +- .../plate_reading/fluorescence/chatterbox.py | 2 + .../fluorescence/fluorescence.py | 7 +- .../fluorescence/fluorescence_tests.py | 2 + .../plate_reading/luminescence/backend.py | 9 +- .../plate_reading/luminescence/chatterbox.py | 7 +- .../luminescence/luminescence.py | 7 +- .../luminescence/luminescence_tests.py | 2 + pylabrobot/legacy/peeling/xpeel_backend.py | 3 +- .../plate_reading/agilent/biotek_backend.py | 5 +- .../bmg_labtech/clario_star_backend.py | 5 +- .../plate_reading/byonoy/byonoy_backend.py | 5 +- pylabrobot/legacy/plate_reading/imager.py | 14 +- .../molecular_devices/backend.py | 41 +++-- .../imageXpress/pico/backend.py | 2 + .../molecular_devices/spectramax/backend.py | 66 ++++---- .../spectramax/backend_tests.py | 5 +- .../spectramax/spectramax_m5.py | 145 ++++++++++-------- 35 files changed, 366 insertions(+), 178 deletions(-) diff --git a/pylabrobot/agilent/biotek/biotek.py b/pylabrobot/agilent/biotek/biotek.py index 45e10b3a0f0..406460beb5a 100644 --- a/pylabrobot/agilent/biotek/biotek.py +++ b/pylabrobot/agilent/biotek/biotek.py @@ -3,6 +3,7 @@ import logging import time from abc import ABCMeta +from dataclasses import dataclass from typing import Dict, Iterable, List, Optional, Tuple from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceBackend, AbsorbanceResult @@ -16,6 +17,7 @@ ) from pylabrobot.io.ftdi import FTDI from pylabrobot.resources import Plate, Well +from pylabrobot.serializer import SerializableMixin logger = logging.getLogger(__name__) @@ -316,7 +318,11 @@ def _get_min_max_row_col_tuples( return self._non_overlapping_rectangles((well.get_row(), well.get_column()) for well in wells) async def read_absorbance( - self, plate: Plate, wells: List[Well], wavelength: int + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, ) -> List[AbsorbanceResult]: min_abs, max_abs = self.abs_wavelength_range if not (min_abs <= wavelength <= max_abs): @@ -360,9 +366,21 @@ async def read_absorbance( ) ] + @dataclass + class LuminescenceParams(SerializableMixin): + integration_time: float = 1 + async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 1 + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, ) -> List[LuminescenceResult]: + if not isinstance(backend_params, self.LuminescenceParams): + backend_params = BioTekBackend.LuminescenceParams() + + integration_time = backend_params.integration_time min_fh, max_fh = self.focal_height_range if not (min_fh <= focal_height <= max_fh): raise ValueError(f"{self.__class__.__name__}: focal height must be within {min_fh}-{max_fh}") @@ -422,6 +440,7 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, + backend_params: Optional[SerializableMixin] = None, ) -> List[FluorescenceResult]: min_fh, max_fh = self.focal_height_range if not (min_fh <= focal_height <= max_fh): diff --git a/pylabrobot/agilent/biotek/biotek_tests.py b/pylabrobot/agilent/biotek/biotek_tests.py index 2e3c5df38e2..e58aeeafe49 100644 --- a/pylabrobot/agilent/biotek/biotek_tests.py +++ b/pylabrobot/agilent/biotek/biotek_tests.py @@ -10,6 +10,7 @@ pytest.importorskip("pylibftdi") +from pylabrobot.agilent.biotek.biotek import BioTekBackend from pylabrobot.agilent.biotek.cytation import CytationBackend from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb, CellVis_96_wellplate_350uL_Fb @@ -228,7 +229,10 @@ async def test_read_luminescence_partial(self): plate = CellVis_96_wellplate_350uL_Fb(name="plate") wells = plate["A1"] + plate["B1:G3"] + plate["D4:F4"] resp = await self.backend.read_luminescence( - focal_height=4.5, integration_time=0.4, plate=plate, wells=wells + focal_height=4.5, + plate=plate, + wells=wells, + backend_params=BioTekBackend.LuminescenceParams(integration_time=0.4), ) self.backend.io.write.assert_any_call(b"D") diff --git a/pylabrobot/agilent/biotek/cytation.py b/pylabrobot/agilent/biotek/cytation.py index effa7538d49..40d0e89c1f5 100644 --- a/pylabrobot/agilent/biotek/cytation.py +++ b/pylabrobot/agilent/biotek/cytation.py @@ -26,6 +26,7 @@ from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability from pylabrobot.device import Device from pylabrobot.resources import Coordinate, Plate, PlateHolder, Resource +from pylabrobot.serializer import SerializableMixin try: import PySpin # type: ignore @@ -745,6 +746,16 @@ async def _acquire_image( await asyncio.sleep(0.3) raise TimeoutError("max_image_read_attempts reached") + @dataclass + class CaptureParams(SerializableMixin): + led_intensity: int = 10 + coverage: Union[Literal["full"], Tuple[int, int]] = (1, 1) + center_position: Optional[Tuple[float, float]] = None + overlap: Optional[float] = None + color_processing_algorithm: int = SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR + pixel_format: int = PixelFormat_Mono8 + auto_stop_acquisition: bool = True + async def capture( self, row: int, @@ -755,14 +766,19 @@ async def capture( focal_height: FocalPosition, gain: Gain, plate: Plate, - led_intensity: int = 10, - coverage: Union[Literal["full"], Tuple[int, int]] = (1, 1), - center_position: Optional[Tuple[float, float]] = None, - overlap: Optional[float] = None, - color_processing_algorithm: int = SPINNAKER_COLOR_PROCESSING_ALGORITHM_HQ_LINEAR, - pixel_format: int = PixelFormat_Mono8, - auto_stop_acquisition=True, + backend_params: Optional[SerializableMixin] = None, ) -> ImagingResult: + if not isinstance(backend_params, self.CaptureParams): + backend_params = CytationBackend.CaptureParams() + + led_intensity = backend_params.led_intensity + coverage = backend_params.coverage + center_position = backend_params.center_position + overlap = backend_params.overlap + color_processing_algorithm = backend_params.color_processing_algorithm + pixel_format = backend_params.pixel_format + auto_stop_acquisition = backend_params.auto_stop_acquisition + assert overlap is None, "not implemented yet" if self._cam is None: diff --git a/pylabrobot/azenta/xpeel.py b/pylabrobot/azenta/xpeel.py index 60697bba976..c3415b0aaf5 100644 --- a/pylabrobot/azenta/xpeel.py +++ b/pylabrobot/azenta/xpeel.py @@ -14,6 +14,7 @@ from pylabrobot.capabilities.peeling import PeelerBackend, PeelingCapability from pylabrobot.device import Device from pylabrobot.io.serial import Serial +from pylabrobot.serializer import SerializableMixin class XPeelBackend(PeelerBackend): @@ -152,23 +153,28 @@ async def reset(self): """Request reset.""" return await self._send_command("*reset", expect_ack=True, wait_for_ready=True) - async def restart(self): + async def restart(self, backend_params: Optional[SerializableMixin] = None): """Request restart with full homing sequence.""" return await self._send_command("*restart", expect_ack=True, wait_for_ready=True) + @dataclass + class PeelParams(SerializableMixin): + begin_location: Literal[-2, 0, 2, 4] = 0 + fast: bool = False + adhere_time: float = 2.5 + async def peel( self, - begin_location: Literal[-2, 0, 2, 4] = 0, - fast: bool = False, - adhere_time: float = 2.5, + backend_params: Optional[SerializableMixin] = None, ): - """Run an automated de-seal cycle. + """Run an automated de-seal cycle.""" + if not isinstance(backend_params, self.PeelParams): + backend_params = XPeelBackend.PeelParams() + + adhere_time = backend_params.adhere_time + begin_location = backend_params.begin_location + fast = backend_params.fast - Args: - begin_location: Begin peel location in mm relative to default. Must be -2, 0, 2, or 4. - fast: Use fast speed if True. - adhere_time: Adhere time in seconds. Must be 2.5, 5.0, 7.5, or 10.0. - """ if adhere_time not in {2.5, 5.0, 7.5, 10.0}: raise ValueError("adhere_time must be one of: 2.5, 5.0, 7.5, 10.0") if begin_location not in {-2, 0, 2, 4}: diff --git a/pylabrobot/bmg_labtech/clariostar.py b/pylabrobot/bmg_labtech/clariostar.py index 8187f6a6a78..bdf957606ea 100644 --- a/pylabrobot/bmg_labtech/clariostar.py +++ b/pylabrobot/bmg_labtech/clariostar.py @@ -4,6 +4,7 @@ import struct import sys import time +from dataclasses import dataclass from typing import List, Optional, Union from pylabrobot.capabilities.plate_reading.absorbance import ( @@ -26,6 +27,7 @@ from pylabrobot.resources import Coordinate, PlateHolder, Resource from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin from pylabrobot.utils.list import reshape_2d if sys.version_info >= (3, 8): @@ -270,7 +272,11 @@ async def _run_absorbance(self, wavelength: float, plate: Plate) -> bytes: # -- Capability methods --------------------------------------------------- async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float = 13 + self, + plate: Plate, + wells: List[Well], + focal_height: float = 13, + backend_params: Optional[SerializableMixin] = None, ) -> List[LuminescenceResult]: if wells != plate.get_all_items(): raise NotImplementedError("Only full plate reads are supported for now.") @@ -298,13 +304,20 @@ async def read_luminescence( ) ] + @dataclass + class AbsorbanceParams(SerializableMixin): + report: Literal["OD", "transmittance"] = "OD" + async def read_absorbance( self, plate: Plate, wells: List[Well], wavelength: int, - report: Literal["OD", "transmittance"] = "OD", + backend_params: Optional[SerializableMixin] = None, ) -> List[AbsorbanceResult]: + if not isinstance(backend_params, self.AbsorbanceParams): + backend_params = CLARIOstarBackend.AbsorbanceParams() + if wells != plate.get_all_items(): raise NotImplementedError("Only full plate reads are supported for now.") @@ -335,15 +348,15 @@ async def read_absorbance( ] data: List[List[Optional[float]]] - if report == "OD": + if backend_params.report == "OD": od: List[Optional[float]] = [ math.log10(100 / t) if t is not None and t > 0 else None for t in transmittance ] data = reshape_2d(od, (plate.num_items_y, plate.num_items_x)) - elif report == "transmittance": + elif backend_params.report == "transmittance": data = reshape_2d(transmittance, (plate.num_items_y, plate.num_items_x)) else: - raise ValueError(f"Invalid report type: {report}") + raise ValueError(f"Invalid report type: {backend_params.report}") return [ AbsorbanceResult( @@ -361,6 +374,7 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, + backend_params: Optional[SerializableMixin] = None, ) -> List[FluorescenceResult]: raise NotImplementedError("CLARIOstar fluorescence reading is not implemented yet.") diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py index 658bae6d325..e9a3e1794ba 100644 --- a/pylabrobot/byonoy/absorbance_96.py +++ b/pylabrobot/byonoy/absorbance_96.py @@ -14,6 +14,7 @@ from pylabrobot.resources.plate import Plate from pylabrobot.resources.rotation import Rotation from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin from pylabrobot.utils.list import reshape_2d # --------------------------------------------------------------------------- @@ -108,7 +109,11 @@ async def initialize_measurements(self): ) async def read_absorbance( - self, plate: Plate, wells: List[Well], wavelength: int + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, ) -> List[AbsorbanceResult]: assert wavelength in self.available_wavelengths, ( f"Wavelength {wavelength} nm not in available wavelengths {self.available_wavelengths}." diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index 462cf8b9248..71a198b3c52 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -1,4 +1,5 @@ import time +from dataclasses import dataclass from typing import List, Optional, Tuple from pylabrobot.byonoy.backend import ByonoyBase, ByonoyDevice @@ -14,6 +15,7 @@ from pylabrobot.resources.plate import Plate from pylabrobot.resources.rotation import Rotation from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin from pylabrobot.utils.list import reshape_2d # --------------------------------------------------------------------------- @@ -27,8 +29,16 @@ class ByonoyLuminescence96Backend(ByonoyBase, LuminescenceBackend): def __init__(self) -> None: super().__init__(pid=0x119B, device_type=ByonoyDevice.LUMINESCENCE_96) + @dataclass + class LuminescenceParams(SerializableMixin): + integration_time: float = 2 + async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 2 + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, ) -> List[LuminescenceResult]: """Read luminescence. @@ -36,8 +46,12 @@ async def read_luminescence( plate: The plate being read. wells: Wells to measure. focal_height: Focal height in mm. - integration_time: Integration time in seconds, default 2 s. + backend_params: Backend-specific parameters. """ + if not isinstance(backend_params, self.LuminescenceParams): + backend_params = ByonoyLuminescence96Backend.LuminescenceParams() + + integration_time = backend_params.integration_time await self.send_command( report_id=0x0010, diff --git a/pylabrobot/capabilities/microscopy/backend.py b/pylabrobot/capabilities/microscopy/backend.py index 3a6a7185315..8b44fe25079 100644 --- a/pylabrobot/capabilities/microscopy/backend.py +++ b/pylabrobot/capabilities/microscopy/backend.py @@ -1,4 +1,5 @@ from abc import ABCMeta, abstractmethod +from typing import Optional from pylabrobot.capabilities.microscopy.standard import ( Exposure, @@ -10,6 +11,7 @@ ) from pylabrobot.device import DeviceBackend from pylabrobot.resources.plate import Plate +from pylabrobot.serializer import SerializableMixin class MicroscopyBackend(DeviceBackend, metaclass=ABCMeta): @@ -26,6 +28,7 @@ async def capture( focal_height: FocalPosition, gain: Gain, plate: Plate, + backend_params: Optional[SerializableMixin] = None, ) -> ImagingResult: """Capture an image at the specified well position. diff --git a/pylabrobot/capabilities/microscopy/chatterbox.py b/pylabrobot/capabilities/microscopy/chatterbox.py index 31fa7c8735f..b161cf44170 100644 --- a/pylabrobot/capabilities/microscopy/chatterbox.py +++ b/pylabrobot/capabilities/microscopy/chatterbox.py @@ -1,3 +1,5 @@ +from typing import Optional + from pylabrobot.capabilities.microscopy.backend import MicroscopyBackend from pylabrobot.capabilities.microscopy.standard import ( Exposure, @@ -8,6 +10,7 @@ Objective, ) from pylabrobot.resources.plate import Plate +from pylabrobot.serializer import SerializableMixin try: import numpy as np # type: ignore @@ -37,6 +40,7 @@ async def capture( focal_height: FocalPosition, gain: Gain, plate: Plate, + backend_params: Optional[SerializableMixin] = None, ) -> ImagingResult: if HAS_NUMPY: image = np.zeros((512, 512), dtype=np.uint16) diff --git a/pylabrobot/capabilities/microscopy/microscopy.py b/pylabrobot/capabilities/microscopy/microscopy.py index 4def712b33e..a977b5bcfbe 100644 --- a/pylabrobot/capabilities/microscopy/microscopy.py +++ b/pylabrobot/capabilities/microscopy/microscopy.py @@ -1,7 +1,7 @@ import logging import math import time -from typing import Dict, Tuple, Union, cast +from typing import Dict, Optional, Tuple, Union, cast from pylabrobot.capabilities.capability import Capability, need_capability_ready from pylabrobot.capabilities.microscopy.standard import ( @@ -16,6 +16,7 @@ ) from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin from .backend import MicroscopyBackend @@ -87,7 +88,7 @@ async def _capture_auto_exposure( focal_height: float, gain: float, plate: Plate, - **backend_kwargs, + backend_params: Optional[SerializableMixin] = None, ) -> ImagingResult: """Capture with iterative auto-exposure using weighted binary search.""" @@ -112,7 +113,7 @@ def _rms_split(low: float, high: float) -> float: focal_height=focal_height, gain=gain, plate=plate, - **backend_kwargs, + backend_params=backend_params, ) assert len(res.images) == 1, "Expected exactly one image for auto-exposure" evaluation = await auto_exposure.evaluate_exposure(res.images[0]) @@ -137,7 +138,7 @@ async def _capture_auto_focus( auto_focus: AutoFocus, gain: float, plate: Plate, - **backend_kwargs, + backend_params: Optional[SerializableMixin] = None, ) -> ImagingResult: """Capture with golden-ratio auto-focus search.""" @@ -150,7 +151,7 @@ async def local_capture(focal_height: float) -> ImagingResult: focal_height=focal_height, gain=gain, plate=plate, - **backend_kwargs, + backend_params=backend_params, ) async def capture_and_evaluate(focal_height: float) -> float: @@ -176,7 +177,7 @@ async def capture( exposure_time: Union[Exposure, AutoExposure] = "machine-auto", focal_height: Union[FocalPosition, AutoFocus] = "machine-auto", gain: Gain = "machine-auto", - **backend_kwargs, + backend_params: Optional[SerializableMixin] = None, ) -> ImagingResult: """Capture an image of a well. @@ -188,7 +189,7 @@ async def capture( exposure_time: Exposure time in ms, :class:`AutoExposure`, or ``"machine-auto"``. focal_height: Focal height in mm, :class:`AutoFocus`, or ``"machine-auto"``. gain: Gain value or ``"machine-auto"``. - **backend_kwargs: Additional keyword arguments passed to the backend. + backend_params: Backend-specific parameters. Returns: An :class:`ImagingResult` with captured image(s) and metadata. @@ -206,7 +207,7 @@ async def capture( focal_height=focal_height, gain=gain, plate=plate, - **backend_kwargs, + backend_params=backend_params, ) if isinstance(focal_height, AutoFocus): @@ -222,7 +223,7 @@ async def capture( auto_focus=focal_height, gain=gain, plate=plate, - **backend_kwargs, + backend_params=backend_params, ) row, column = self._resolve_well(well) @@ -235,7 +236,7 @@ async def capture( focal_height=focal_height, gain=gain, plate=plate, - **backend_kwargs, + backend_params=backend_params, ) async def _on_stop(self): diff --git a/pylabrobot/capabilities/microscopy/microscopy_tests.py b/pylabrobot/capabilities/microscopy/microscopy_tests.py index 4a71aa6663f..70abd42b7b4 100644 --- a/pylabrobot/capabilities/microscopy/microscopy_tests.py +++ b/pylabrobot/capabilities/microscopy/microscopy_tests.py @@ -1,7 +1,7 @@ """Tests for MicroscopyCapability.""" import unittest -from typing import List, Tuple +from typing import List, Optional, Tuple import pytest @@ -24,6 +24,7 @@ from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType +from pylabrobot.serializer import SerializableMixin def _test_plate() -> Plate: @@ -73,6 +74,7 @@ async def capture( focal_height: FocalPosition, gain: Gain, plate: Plate, + backend_params: Optional[SerializableMixin] = None, ) -> ImagingResult: self.calls.append((row, column, mode, objective, exposure_time, focal_height, gain)) return ImagingResult( diff --git a/pylabrobot/capabilities/peeling/backend.py b/pylabrobot/capabilities/peeling/backend.py index 453b929ee2c..e2663517b12 100644 --- a/pylabrobot/capabilities/peeling/backend.py +++ b/pylabrobot/capabilities/peeling/backend.py @@ -1,15 +1,17 @@ from abc import ABCMeta, abstractmethod +from typing import Optional from pylabrobot.device import DeviceBackend +from pylabrobot.serializer import SerializableMixin class PeelerBackend(DeviceBackend, metaclass=ABCMeta): """Abstract backend for peeling devices.""" @abstractmethod - async def peel(self): + async def peel(self, backend_params: Optional[SerializableMixin] = None): """Run an automated de-seal cycle.""" @abstractmethod - async def restart(self): + async def restart(self, backend_params: Optional[SerializableMixin] = None): """Restart the peeler machine.""" diff --git a/pylabrobot/capabilities/peeling/peeling.py b/pylabrobot/capabilities/peeling/peeling.py index 0f7b0e47c12..6a9f0e162f7 100644 --- a/pylabrobot/capabilities/peeling/peeling.py +++ b/pylabrobot/capabilities/peeling/peeling.py @@ -1,4 +1,7 @@ +from typing import Optional + from pylabrobot.capabilities.capability import Capability +from pylabrobot.serializer import SerializableMixin from .backend import PeelerBackend @@ -10,13 +13,13 @@ def __init__(self, backend: PeelerBackend): super().__init__(backend=backend) self.backend: PeelerBackend = backend - async def peel(self, **backend_kwargs): + async def peel(self, backend_params: Optional[SerializableMixin] = None): """Run an automated de-seal cycle.""" - return await self.backend.peel(**backend_kwargs) + return await self.backend.peel(backend_params=backend_params) - async def restart(self, **backend_kwargs): + async def restart(self, backend_params: Optional[SerializableMixin] = None): """Restart the peeler.""" - return await self.backend.restart(**backend_kwargs) + return await self.backend.restart(backend_params=backend_params) async def _on_stop(self): await super()._on_stop() diff --git a/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py b/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py index 29c195c66cd..d8cd7b54076 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py @@ -7,6 +7,7 @@ from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin from .backend import AbsorbanceBackend @@ -26,7 +27,7 @@ async def read( plate: Plate, wavelength: int, wells: Optional[List[Well]] = None, - **backend_kwargs, + backend_params: Optional[SerializableMixin] = None, ) -> List[AbsorbanceResult]: """Read absorbance from a plate. @@ -34,7 +35,7 @@ async def read( plate: The plate to read. wavelength: Wavelength in nm. wells: Wells to measure. Defaults to all wells in the plate. - **backend_kwargs: Additional keyword arguments passed to the backend. + backend_params: Backend-specific parameters. Returns: A list of :class:`AbsorbanceResult` (typically length 1). @@ -42,5 +43,5 @@ async def read( if wells is None: wells = plate.get_all_items() return await self.backend.read_absorbance( - plate=plate, wells=wells, wavelength=wavelength, **backend_kwargs + plate=plate, wells=wells, wavelength=wavelength, backend_params=backend_params ) diff --git a/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py index 186dbb07364..c5c95d3a933 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py @@ -13,6 +13,7 @@ from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType +from pylabrobot.serializer import SerializableMixin def _test_plate() -> Plate: @@ -53,7 +54,11 @@ async def stop(self) -> None: pass async def read_absorbance( - self, plate: Plate, wells: List[Well], wavelength: int + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, ) -> List[AbsorbanceResult]: self.calls.append((plate, wells, wavelength)) data: List[List[Optional[float]]] = [ diff --git a/pylabrobot/capabilities/plate_reading/absorbance/backend.py b/pylabrobot/capabilities/plate_reading/absorbance/backend.py index dc08216e883..3b44509c1c8 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/backend.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/backend.py @@ -1,12 +1,13 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import List +from typing import List, Optional from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult from pylabrobot.device import DeviceBackend from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin class AbsorbanceBackend(DeviceBackend, metaclass=ABCMeta): @@ -14,7 +15,11 @@ class AbsorbanceBackend(DeviceBackend, metaclass=ABCMeta): @abstractmethod async def read_absorbance( - self, plate: Plate, wells: List[Well], wavelength: int + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, ) -> List[AbsorbanceResult]: """Read absorbance for the given wells. diff --git a/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py b/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py index c8f2ea5ca15..12f52a460d8 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py @@ -6,6 +6,7 @@ from pylabrobot.capabilities.plate_reading.utils import mask_wells from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin class AbsorbanceChatterboxBackend(AbsorbanceBackend): @@ -21,7 +22,11 @@ async def stop(self) -> None: pass async def read_absorbance( - self, plate: Plate, wells: List[Well], wavelength: int + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, ) -> List[AbsorbanceResult]: data = mask_wells(self.dummy_absorbance, wells, plate) return [ diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/backend.py b/pylabrobot/capabilities/plate_reading/fluorescence/backend.py index 02494981d7e..9849538da31 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/backend.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/backend.py @@ -1,12 +1,13 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import List +from typing import List, Optional from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult from pylabrobot.device import DeviceBackend from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin class FluorescenceBackend(DeviceBackend, metaclass=ABCMeta): @@ -20,6 +21,7 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, + backend_params: Optional[SerializableMixin] = None, ) -> List[FluorescenceResult]: """Read fluorescence for the given wells. diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py b/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py index 3324b96a8da..3a22324cecc 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py @@ -6,6 +6,7 @@ from pylabrobot.capabilities.plate_reading.utils import mask_wells from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin class FluorescenceChatterboxBackend(FluorescenceBackend): @@ -27,6 +28,7 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, + backend_params: Optional[SerializableMixin] = None, ) -> List[FluorescenceResult]: data = mask_wells(self.dummy_fluorescence, wells, plate) return [ diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py index 1f43e955567..e7aa0978195 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py @@ -7,6 +7,7 @@ from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin from .backend import FluorescenceBackend @@ -28,7 +29,7 @@ async def read( emission_wavelength: int, focal_height: float, wells: Optional[List[Well]] = None, - **backend_kwargs, + backend_params: Optional[SerializableMixin] = None, ) -> List[FluorescenceResult]: """Read fluorescence from a plate. @@ -38,7 +39,7 @@ async def read( emission_wavelength: Emission wavelength in nm. focal_height: Focal height in mm. wells: Wells to measure. Defaults to all wells in the plate. - **backend_kwargs: Additional keyword arguments passed to the backend. + backend_params: Backend-specific parameters. Returns: A list of :class:`FluorescenceResult` (typically length 1). @@ -51,5 +52,5 @@ async def read( excitation_wavelength=excitation_wavelength, emission_wavelength=emission_wavelength, focal_height=focal_height, - **backend_kwargs, + backend_params=backend_params, ) diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py index 2b05596146c..d75fa776684 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py @@ -13,6 +13,7 @@ from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType +from pylabrobot.serializer import SerializableMixin def _test_plate() -> Plate: @@ -59,6 +60,7 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, + backend_params: Optional[SerializableMixin] = None, ) -> List[FluorescenceResult]: self.calls.append( ("read_fluorescence", len(wells), excitation_wavelength, emission_wavelength, focal_height) diff --git a/pylabrobot/capabilities/plate_reading/luminescence/backend.py b/pylabrobot/capabilities/plate_reading/luminescence/backend.py index a836d643ee3..3bb4888c261 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/backend.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/backend.py @@ -1,12 +1,13 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import List +from typing import List, Optional from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult from pylabrobot.device import DeviceBackend from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin class LuminescenceBackend(DeviceBackend, metaclass=ABCMeta): @@ -14,7 +15,11 @@ class LuminescenceBackend(DeviceBackend, metaclass=ABCMeta): @abstractmethod async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, ) -> List[LuminescenceResult]: """Read luminescence for the given wells. diff --git a/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py b/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py index fbc089383f8..69cf1981b09 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py @@ -6,6 +6,7 @@ from pylabrobot.capabilities.plate_reading.utils import mask_wells from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin class LuminescenceChatterboxBackend(LuminescenceBackend): @@ -21,7 +22,11 @@ async def stop(self) -> None: pass async def read_luminescence( - self, plate: Plate, wells: List[Well], focal_height: float + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, ) -> List[LuminescenceResult]: data = mask_wells(self.dummy_luminescence, wells, plate) return [ diff --git a/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py b/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py index e400117fd66..8d1af79789b 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py @@ -7,6 +7,7 @@ from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin from .backend import LuminescenceBackend @@ -26,7 +27,7 @@ async def read( plate: Plate, focal_height: float, wells: Optional[List[Well]] = None, - **backend_kwargs, + backend_params: Optional[SerializableMixin] = None, ) -> List[LuminescenceResult]: """Read luminescence from a plate. @@ -34,7 +35,7 @@ async def read( plate: The plate to read. focal_height: Focal height in mm. wells: Wells to measure. Defaults to all wells in the plate. - **backend_kwargs: Additional keyword arguments passed to the backend. + backend_params: Backend-specific parameters. Returns: A list of :class:`LuminescenceResult` (typically length 1). @@ -42,5 +43,5 @@ async def read( if wells is None: wells = plate.get_all_items() return await self.backend.read_luminescence( - plate=plate, wells=wells, focal_height=focal_height, **backend_kwargs + plate=plate, wells=wells, focal_height=focal_height, backend_params=backend_params ) diff --git a/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py index 5270564a792..062569b3a2a 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py @@ -13,6 +13,7 @@ from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType +from pylabrobot.serializer import SerializableMixin def _test_plate() -> Plate: @@ -57,6 +58,7 @@ async def read_luminescence( plate: Plate, wells: List[Well], focal_height: float, + backend_params: Optional[SerializableMixin] = None, ) -> List[LuminescenceResult]: self.calls.append(("read_luminescence", len(wells), focal_height)) data: List[List[Optional[float]]] = [ diff --git a/pylabrobot/legacy/peeling/xpeel_backend.py b/pylabrobot/legacy/peeling/xpeel_backend.py index 0445d9ddeb9..ed198d4fefb 100644 --- a/pylabrobot/legacy/peeling/xpeel_backend.py +++ b/pylabrobot/legacy/peeling/xpeel_backend.py @@ -20,7 +20,8 @@ def serialize(self) -> dict: return self._new.serialize() async def peel(self, **kwargs): - return await self._new.peel(**kwargs) + params = xpeel.XPeelBackend.PeelParams(**kwargs) if kwargs else None + return await self._new.peel(backend_params=params) async def restart(self): return await self._new.restart() diff --git a/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py b/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py index 69120b5e2ba..343359a8261 100644 --- a/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py +++ b/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py @@ -158,8 +158,11 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 1 ) -> List[Dict]: + from pylabrobot.agilent.biotek.biotek import BioTekBackend + + params = BioTekBackend.LuminescenceParams(integration_time=integration_time) results = await self._new.read_luminescence( - plate=plate, wells=wells, focal_height=focal_height, integration_time=integration_time + plate=plate, wells=wells, focal_height=focal_height, backend_params=params ) return [ { diff --git a/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py b/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py index ede3155d9f9..d6d326db08d 100644 --- a/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py +++ b/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py @@ -55,8 +55,11 @@ async def read_absorbance( wavelength: int, report: Literal["OD", "transmittance"] = "OD", ) -> List[Dict]: + from pylabrobot.bmg_labtech.clariostar import CLARIOstarBackend + + params = CLARIOstarBackend.AbsorbanceParams(report=report) results = await self._new.read_absorbance( - plate=plate, wells=wells, wavelength=wavelength, report=report + plate=plate, wells=wells, wavelength=wavelength, backend_params=params ) return [ { diff --git a/pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py b/pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py index 4d976417b24..16b4d2e341a 100644 --- a/pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py +++ b/pylabrobot/legacy/plate_reading/byonoy/byonoy_backend.py @@ -94,8 +94,11 @@ async def read_absorbance(self, plate: Plate, wells: List[Well], wavelength: int async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float, integration_time: float = 2 ) -> List[Dict]: + from pylabrobot.byonoy.luminescence_96 import ByonoyLuminescence96Backend + + params = ByonoyLuminescence96Backend.LuminescenceParams(integration_time=integration_time) results = await self._new.read_luminescence( - plate=plate, wells=wells, focal_height=focal_height, integration_time=integration_time + plate=plate, wells=wells, focal_height=focal_height, backend_params=params ) return [ { diff --git a/pylabrobot/legacy/plate_reading/imager.py b/pylabrobot/legacy/plate_reading/imager.py index 827e10077ad..9e13074f611 100644 --- a/pylabrobot/legacy/plate_reading/imager.py +++ b/pylabrobot/legacy/plate_reading/imager.py @@ -48,7 +48,18 @@ async def setup(self) -> None: async def stop(self) -> None: await self._legacy.stop() - async def capture(self, row, column, mode, objective, exposure_time, focal_height, gain, plate): + async def capture( + self, + row, + column, + mode, + objective, + exposure_time, + focal_height, + gain, + plate, + backend_params=None, + ): legacy_mode = ImagingMode[mode.name] legacy_obj = Objective[objective.name] result = await self._legacy.capture( @@ -155,6 +166,5 @@ async def capture( exposure_time=new_exposure, focal_height=focal_height, gain=gain, - **backend_kwargs, ) return _to_legacy_result(new_result) diff --git a/pylabrobot/legacy/plate_reading/molecular_devices/backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py index 1383dad3da9..7ec7777490f 100644 --- a/pylabrobot/legacy/plate_reading/molecular_devices/backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py @@ -111,12 +111,13 @@ async def read_absorbance( # type: ignore[override] settling_time: int = 0, timeout: int = 600, ) -> List[Dict]: + from pylabrobot.molecular_devices.spectramax.backend import ( + MolecularDevicesBackend as NewMDBackend, + ) + wl0 = wavelengths[0] wavelength = wl0[0] if isinstance(wl0, tuple) else wl0 - results = await self._new.read_absorbance( - plate=plate, - wells=[], - wavelength=wavelength, + params = NewMDBackend.AbsorbanceParams( wavelengths=wavelengths, read_type=read_type, read_order=read_order, @@ -131,6 +132,12 @@ async def read_absorbance( # type: ignore[override] settling_time=settling_time, timeout=timeout, ) + results = await self._new.read_absorbance( + plate=plate, + wells=[], + wavelength=wavelength, + backend_params=params, + ) return [ { "wavelength": r.wavelength, @@ -161,12 +168,7 @@ async def read_fluorescence( # type: ignore[override] settling_time: int = 0, timeout: int = 600, ) -> List[Dict]: - results = await self._new.read_fluorescence( - plate=plate, - wells=[], - excitation_wavelength=excitation_wavelengths[0], - emission_wavelength=emission_wavelengths[0], - focal_height=0, + params = SpectraMaxM5Backend.FluorescenceParams( excitation_wavelengths=excitation_wavelengths, emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, @@ -184,6 +186,14 @@ async def read_fluorescence( # type: ignore[override] settling_time=settling_time, timeout=timeout, ) + results = await self._new.read_fluorescence( + plate=plate, + wells=[], + excitation_wavelength=excitation_wavelengths[0], + emission_wavelength=emission_wavelengths[0], + focal_height=0, + backend_params=params, + ) return [ { "ex_wavelength": r.excitation_wavelength, @@ -213,10 +223,7 @@ async def read_luminescence( # type: ignore[override] settling_time: int = 0, timeout: int = 600, ) -> List[Dict]: - results = await self._new.read_luminescence( - plate=plate, - wells=[], - focal_height=0, + params = SpectraMaxM5Backend.LuminescenceParams( emission_wavelengths=emission_wavelengths, read_type=read_type, read_order=read_order, @@ -232,6 +239,12 @@ async def read_luminescence( # type: ignore[override] settling_time=settling_time, timeout=timeout, ) + results = await self._new.read_luminescence( + plate=plate, + wells=[], + focal_height=0, + backend_params=params, + ) return [{"data": r.data, "temperature": r.temperature, "time": r.timestamp} for r in results] async def read_fluorescence_polarization( diff --git a/pylabrobot/molecular_devices/imageXpress/pico/backend.py b/pylabrobot/molecular_devices/imageXpress/pico/backend.py index eb86194d0fe..9f411bad19a 100644 --- a/pylabrobot/molecular_devices/imageXpress/pico/backend.py +++ b/pylabrobot/molecular_devices/imageXpress/pico/backend.py @@ -36,6 +36,7 @@ from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import row_index_to_label from pylabrobot.resources.well import WellBottomType +from pylabrobot.serializer import SerializableMixin try: import grpc # type: ignore[import-untyped] @@ -650,6 +651,7 @@ async def capture( focal_height: FocalPosition, gain: Gain, plate: Plate, + backend_params: Optional[SerializableMixin] = None, ) -> ImagingResult: if mode not in _IMAGING_MODE_MAP: raise ValueError( diff --git a/pylabrobot/molecular_devices/spectramax/backend.py b/pylabrobot/molecular_devices/spectramax/backend.py index 870502c3025..42f6dee8c0e 100644 --- a/pylabrobot/molecular_devices/spectramax/backend.py +++ b/pylabrobot/molecular_devices/spectramax/backend.py @@ -12,6 +12,7 @@ from pylabrobot.io.serial import Serial from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin logger = logging.getLogger("pylabrobot") @@ -710,46 +711,53 @@ async def _wait_for_idle(self, timeout: int = 600): break await asyncio.sleep(1) + @dataclass + class AbsorbanceParams(SerializableMixin): + wavelengths: Optional[List[Union[int, Tuple[int, bool]]]] = None + read_type: ReadType = ReadType.ENDPOINT + read_order: ReadOrder = ReadOrder.COLUMN + calibrate: Calibrate = Calibrate.ONCE + shake_settings: Optional[ShakeSettings] = None + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL + speed_read: bool = False + path_check: bool = False + kinetic_settings: Optional[KineticSettings] = None + spectrum_settings: Optional[SpectrumSettings] = None + cuvette: bool = False + settling_time: int = 0 + timeout: int = 600 + async def read_absorbance( self, plate: Plate, wells: List[Well], wavelength: int, - *, - wavelengths: Optional[List[Union[int, Tuple[int, bool]]]] = None, - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - speed_read: bool = False, - path_check: bool = False, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False, - settling_time: int = 0, - timeout: int = 600, + backend_params: Optional[SerializableMixin] = None, ) -> List[AbsorbanceResult]: - if wavelengths is None: - wavelengths = [wavelength] + if not isinstance(backend_params, self.AbsorbanceParams): + backend_params = MolecularDevicesBackend.AbsorbanceParams() + + wavelengths = ( + backend_params.wavelengths if backend_params.wavelengths is not None else [wavelength] + ) settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.ABS, - read_type=read_type, - read_order=read_order, - calibrate=calibrate, - shake_settings=shake_settings, - carriage_speed=carriage_speed, - speed_read=speed_read, - path_check=path_check, - kinetic_settings=kinetic_settings, - spectrum_settings=spectrum_settings, + read_type=backend_params.read_type, + read_order=backend_params.read_order, + calibrate=backend_params.calibrate, + shake_settings=backend_params.shake_settings, + carriage_speed=backend_params.carriage_speed, + speed_read=backend_params.speed_read, + path_check=backend_params.path_check, + kinetic_settings=backend_params.kinetic_settings, + spectrum_settings=backend_params.spectrum_settings, wavelengths=wavelengths, - cuvette=cuvette, - settling_time=settling_time, + cuvette=backend_params.cuvette, + settling_time=backend_params.settling_time, ) await self._set_clear() - if not cuvette: + if not backend_params.cuvette: await self._set_plate_position(settings) await self._set_strip(settings) await self._set_carriage_speed(settings) @@ -765,7 +773,7 @@ async def read_absorbance( await self._set_readtype(settings) await self._read_now() - await self._wait_for_idle(timeout=timeout) + await self._wait_for_idle(timeout=backend_params.timeout) dicts = await self._transfer_data(settings) return [ AbsorbanceResult( diff --git a/pylabrobot/molecular_devices/spectramax/backend_tests.py b/pylabrobot/molecular_devices/spectramax/backend_tests.py index 5f13a24882b..c55b576a330 100644 --- a/pylabrobot/molecular_devices/spectramax/backend_tests.py +++ b/pylabrobot/molecular_devices/spectramax/backend_tests.py @@ -599,7 +599,10 @@ async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_w async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") results = await self.backend.read_luminescence( - plate, plate.get_wells(), focal_height=0, emission_wavelengths=[590] + plate, + plate.get_wells(), + focal_height=0, + backend_params=SpectraMaxM5Backend.LuminescenceParams(emission_wavelengths=[590]), ) # Verify typed results diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py index 7f452bec9f2..fb2d4dc4dcd 100644 --- a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py +++ b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from typing import Dict, List, Optional, Union from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability @@ -12,6 +13,7 @@ from pylabrobot.resources import Coordinate, PlateHolder, Resource from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin from .backend import ( Calibrate, @@ -38,6 +40,25 @@ class SpectraMaxM5Backend(MolecularDevicesBackend, FluorescenceBackend, Luminesc def __init__(self, port: str) -> None: super().__init__(port, human_readable_device_name="Molecular Devices SpectraMax M5") + @dataclass + class FluorescenceParams(SerializableMixin): + excitation_wavelengths: Optional[List[int]] = None + emission_wavelengths: Optional[List[int]] = None + cutoff_filters: Optional[List[int]] = None + read_type: ReadType = ReadType.ENDPOINT + read_order: ReadOrder = ReadOrder.COLUMN + calibrate: Calibrate = Calibrate.ONCE + shake_settings: Optional[ShakeSettings] = None + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL + read_from_bottom: bool = False + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO + flashes_per_well: int = 10 + kinetic_settings: Optional[KineticSettings] = None + spectrum_settings: Optional[SpectrumSettings] = None + cuvette: bool = False + settling_time: int = 0 + timeout: int = 600 + async def read_fluorescence( self, plate: Plate, @@ -45,53 +66,39 @@ async def read_fluorescence( excitation_wavelength: int, emission_wavelength: int, focal_height: float, - *, - excitation_wavelengths: Optional[List[int]] = None, - emission_wavelengths: Optional[List[int]] = None, - cutoff_filters: Optional[List[int]] = None, - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 10, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False, - settling_time: int = 0, - timeout: int = 600, + backend_params: Optional[SerializableMixin] = None, ) -> List[FluorescenceResult]: - if excitation_wavelengths is None: - excitation_wavelengths = [excitation_wavelength] - if emission_wavelengths is None: - emission_wavelengths = [emission_wavelength] + if not isinstance(backend_params, self.FluorescenceParams): + backend_params = SpectraMaxM5Backend.FluorescenceParams() + + excitation_wavelengths = backend_params.excitation_wavelengths or [excitation_wavelength] + emission_wavelengths = backend_params.emission_wavelengths or [emission_wavelength] + cutoff_filters = backend_params.cutoff_filters if cutoff_filters is None: cutoff_filters = [self._get_cutoff_filter_index_from_wavelength(emission_wavelength)] settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.FLU, - read_type=read_type, - read_order=read_order, - calibrate=calibrate, - shake_settings=shake_settings, - carriage_speed=carriage_speed, - read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, - flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, - spectrum_settings=spectrum_settings, + read_type=backend_params.read_type, + read_order=backend_params.read_order, + calibrate=backend_params.calibrate, + shake_settings=backend_params.shake_settings, + carriage_speed=backend_params.carriage_speed, + read_from_bottom=backend_params.read_from_bottom, + pmt_gain=backend_params.pmt_gain, + flashes_per_well=backend_params.flashes_per_well, + kinetic_settings=backend_params.kinetic_settings, + spectrum_settings=backend_params.spectrum_settings, excitation_wavelengths=excitation_wavelengths, emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, - cuvette=cuvette, + cuvette=backend_params.cuvette, speed_read=False, - settling_time=settling_time, + settling_time=backend_params.settling_time, ) await self._set_clear() - if not cuvette: + if not backend_params.cuvette: await self._set_plate_position(settings) await self._set_strip(settings) await self._set_carriage_speed(settings) @@ -110,7 +117,7 @@ async def read_fluorescence( await self._set_readtype(settings) await self._read_now() - await self._wait_for_idle(timeout=timeout) + await self._wait_for_idle(timeout=backend_params.timeout) dicts = await self._transfer_data(settings) return [ FluorescenceResult( @@ -123,52 +130,58 @@ async def read_fluorescence( for d in dicts ] + @dataclass + class LuminescenceParams(SerializableMixin): + emission_wavelengths: Optional[List[int]] = None + read_type: ReadType = ReadType.ENDPOINT + read_order: ReadOrder = ReadOrder.COLUMN + calibrate: Calibrate = Calibrate.ONCE + shake_settings: Optional[ShakeSettings] = None + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL + read_from_bottom: bool = False + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO + flashes_per_well: int = 0 + kinetic_settings: Optional[KineticSettings] = None + spectrum_settings: Optional[SpectrumSettings] = None + cuvette: bool = False + settling_time: int = 0 + timeout: int = 600 + async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float, - *, - emission_wavelengths: Optional[List[int]] = None, - read_type: ReadType = ReadType.ENDPOINT, - read_order: ReadOrder = ReadOrder.COLUMN, - calibrate: Calibrate = Calibrate.ONCE, - shake_settings: Optional[ShakeSettings] = None, - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL, - read_from_bottom: bool = False, - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO, - flashes_per_well: int = 0, - kinetic_settings: Optional[KineticSettings] = None, - spectrum_settings: Optional[SpectrumSettings] = None, - cuvette: bool = False, - settling_time: int = 0, - timeout: int = 600, + backend_params: Optional[SerializableMixin] = None, ) -> List[LuminescenceResult]: - if emission_wavelengths is None: + if not isinstance(backend_params, self.LuminescenceParams): + backend_params = SpectraMaxM5Backend.LuminescenceParams() + + if backend_params.emission_wavelengths is None: raise ValueError("emission_wavelengths is required for SpectraMax M5 luminescence reads") settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.LUM, - read_type=read_type, - read_order=read_order, - calibrate=calibrate, - shake_settings=shake_settings, - carriage_speed=carriage_speed, - read_from_bottom=read_from_bottom, - pmt_gain=pmt_gain, - flashes_per_well=flashes_per_well, - kinetic_settings=kinetic_settings, - spectrum_settings=spectrum_settings, - emission_wavelengths=emission_wavelengths, - cuvette=cuvette, + read_type=backend_params.read_type, + read_order=backend_params.read_order, + calibrate=backend_params.calibrate, + shake_settings=backend_params.shake_settings, + carriage_speed=backend_params.carriage_speed, + read_from_bottom=backend_params.read_from_bottom, + pmt_gain=backend_params.pmt_gain, + flashes_per_well=backend_params.flashes_per_well, + kinetic_settings=backend_params.kinetic_settings, + spectrum_settings=backend_params.spectrum_settings, + emission_wavelengths=backend_params.emission_wavelengths, + cuvette=backend_params.cuvette, speed_read=False, - settling_time=settling_time, + settling_time=backend_params.settling_time, ) await self._set_clear() await self._set_read_stage(settings) - if not cuvette: + if not backend_params.cuvette: await self._set_plate_position(settings) await self._set_strip(settings) await self._set_carriage_speed(settings) @@ -185,7 +198,7 @@ async def read_luminescence( await self._set_readtype(settings) await self._read_now() - await self._wait_for_idle(timeout=timeout) + await self._wait_for_idle(timeout=backend_params.timeout) dicts = await self._transfer_data(settings) return [ LuminescenceResult( From fff620c29e0645b58eaf9038fc7c1fa3a59b8909 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 14:35:06 -0700 Subject: [PATCH 03/69] Move VSpin/Access2 to capability composition architecture (#956) Co-authored-by: Claude Opus 4.6 (1M context) --- pylabrobot/agilent/__init__.py | 1 + pylabrobot/agilent/vspin/__init__.py | 1 + pylabrobot/agilent/vspin/vspin.py | 699 ++++++++++++++++++ .../capabilities/centrifuging/__init__.py | 9 + .../capabilities/centrifuging/backend.py | 56 ++ .../capabilities/centrifuging/centrifuging.py | 74 ++ .../capabilities/centrifuging/errors.py | 18 + pylabrobot/legacy/centrifuge/standard.py | 27 +- pylabrobot/legacy/centrifuge/vspin_backend.py | 654 +++------------- 9 files changed, 962 insertions(+), 577 deletions(-) create mode 100644 pylabrobot/agilent/vspin/__init__.py create mode 100644 pylabrobot/agilent/vspin/vspin.py create mode 100644 pylabrobot/capabilities/centrifuging/__init__.py create mode 100644 pylabrobot/capabilities/centrifuging/backend.py create mode 100644 pylabrobot/capabilities/centrifuging/centrifuging.py create mode 100644 pylabrobot/capabilities/centrifuging/errors.py diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py index b3c63105d76..e9e9912b1c2 100644 --- a/pylabrobot/agilent/__init__.py +++ b/pylabrobot/agilent/__init__.py @@ -8,3 +8,4 @@ SynergyH1, SynergyH1Backend, ) +from .vspin import Access2, Access2Backend, VSpin, VSpinBackend diff --git a/pylabrobot/agilent/vspin/__init__.py b/pylabrobot/agilent/vspin/__init__.py new file mode 100644 index 00000000000..50891a35b62 --- /dev/null +++ b/pylabrobot/agilent/vspin/__init__.py @@ -0,0 +1 @@ +from .vspin import Access2, Access2Backend, VSpin, VSpinBackend diff --git a/pylabrobot/agilent/vspin/vspin.py b/pylabrobot/agilent/vspin/vspin.py new file mode 100644 index 00000000000..4a6a01bc85f --- /dev/null +++ b/pylabrobot/agilent/vspin/vspin.py @@ -0,0 +1,699 @@ +import asyncio +import ctypes +import json +import logging +import math +import os +import time +import warnings +from dataclasses import dataclass +from typing import Optional + +from pylabrobot.capabilities.centrifuging import CentrifugeBackend as _NewCentrifugeBackend +from pylabrobot.capabilities.centrifuging import CentrifugingCapability +from pylabrobot.capabilities.centrifuging.errors import ( + BucketHasPlateError, + BucketNoPlateError, + CentrifugeDoorError, + LoaderNoPlateError, + NotAtBucketError, +) +from pylabrobot.device import Device, DeviceBackend +from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources import Coordinate, Resource, ResourceHolder +from pylabrobot.serializer import SerializableMixin + +logger = logging.getLogger(__name__) + + +_vspin_bucket_calibrations_path = os.path.join( + os.path.expanduser("~"), + ".pylabrobot", + "vspin_bucket_calibrations.json", +) + + +def _load_vspin_calibrations(device_id: str) -> Optional[int]: + if not os.path.exists(_vspin_bucket_calibrations_path): + warnings.warn( + f"No calibration found for VSpin with device id {device_id}. " + "Please set the bucket 1 position using `set_bucket_1_position_to_current` method after setup.", + UserWarning, + ) + return None + with open(_vspin_bucket_calibrations_path, "r") as f: + return json.load(f).get(device_id) # type: ignore + + +def _save_vspin_calibrations(device_id, remainder: int): + if os.path.exists(_vspin_bucket_calibrations_path): + with open(_vspin_bucket_calibrations_path, "r") as f: + data = json.load(f) + else: + data = {} + data[device_id] = remainder + os.makedirs(os.path.dirname(_vspin_bucket_calibrations_path), exist_ok=True) + with open(_vspin_bucket_calibrations_path, "w") as f: + json.dump(data, f) + + +FULL_ROTATION: int = 8000 + + +bucket_1_not_set_error = RuntimeError( + "Bucket 1 position not set. " + "Please rotate the bucket to bucket 1 using VSpinBackend.go_to_position and " + "then calling VSpinBackend.set_bucket_1_position_to_current." +) + + +class VSpinBackend(_NewCentrifugeBackend): + """Backend for the Agilent VSpin Centrifuge.""" + + def __init__(self, device_id: Optional[str] = None): + """ + Args: + device_id: The libftdi id for the centrifuge. Find using + `python -m pylibftdi.examples.list_devices` + """ + super().__init__() + self.io = FTDI(human_readable_device_name="Agilent VSpin Centrifuge", device_id=device_id) + self._bucket_1_remainder: Optional[int] = None + if device_id is not None: + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + async def setup(self): + await self.io.setup() + for _ in range(3): + await self.configure_and_initialize() + await self._send_command(bytes.fromhex("aa002101ff21")) + await self._send_command(bytes.fromhex("aa002101ff21")) + await self._send_command(bytes.fromhex("aa01132034")) + await self._send_command(bytes.fromhex("aa002102ff22")) + await self._send_command(bytes.fromhex("aa02132035")) + await self._send_command(bytes.fromhex("aa002103ff23")) + await self._send_command(bytes.fromhex("aaff1a142d")) + + await self.io.set_baudrate(57600) + await self.io.set_rts(True) + await self.io.set_dtr(True) + + await self._send_command(bytes.fromhex("aa01121f32")) + for _ in range(8): + await self._send_command(bytes.fromhex("aa0220ff0f30")) + await self._send_command(bytes.fromhex("aa0220df0f10")) + await self._send_command(bytes.fromhex("aa0220df0e0f")) + await self._send_command(bytes.fromhex("aa0220df0c0d")) + await self._send_command(bytes.fromhex("aa0220df0809")) + for _ in range(4): + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa02120317")) + for _ in range(5): + await self._send_command(bytes.fromhex("aa0226200048")) + await self._send_command(bytes.fromhex("aa0226000028")) + await self.lock_door() + + await self._send_command(bytes.fromhex("aa0226000028")) + + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa010001")) + await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self._send_command(bytes.fromhex("aa01192842")) + + resp = 0x89 + while resp == 0x89: + resp = (await self._get_positions_and_tachometer()).status + + # --- almost the same as go to position --- + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + new_position = (0).to_bytes(4, byteorder="little") # arbitrary + await self._send_command( + bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") + ) + # ----------------------------------------- + + resp = 0x08 + while resp != 0x09: + resp = (await self._get_positions_and_tachometer()).status + + await self._send_command(bytes.fromhex("aa0117021a")) + + await self.lock_door() + + # If we have not set the calibration yet, load it now. + if self._bucket_1_remainder is None: + device_id = await self.io.get_serial() + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + @property + def bucket_1_remainder(self) -> int: + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + return self._bucket_1_remainder + + async def set_bucket_1_position_to_current(self) -> None: + """Set the current position as bucket 1 position and save calibration.""" + current_position = await self.get_position() + device_id = await self.io.get_serial() + remainder = await self.get_home_position() - current_position + self._bucket_1_remainder = current_position % FULL_ROTATION + _save_vspin_calibrations(device_id, remainder) + + async def get_bucket_1_position(self) -> int: + """Get the bucket 1 position based on calibration.""" + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + home_position = await self.get_home_position() + bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder + current_position = await self.get_position() + bucket_1_position = ( + FULL_ROTATION + * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) + + bucket_1_position_mod_full_rotation + ) + return bucket_1_position + + async def stop(self): + await self.configure_and_initialize() + await self.io.stop() + + class _StatusPositionTachometer(ctypes.LittleEndianStructure): + _pack_ = 1 + _fields_ = [ + ("status", ctypes.c_uint8), + ("current_position", ctypes.c_uint32), + ("unknown1", ctypes.c_uint8), + ("tachometer", ctypes.c_int16), + ("unknown2", ctypes.c_uint8), + ("home_position", ctypes.c_uint32), + ("checksum", ctypes.c_uint8), + ] + + async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: + resp = await self._send_command(bytes.fromhex("aa010e0f")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge") + return VSpinBackend._StatusPositionTachometer.from_buffer_copy(resp) + + async def get_position(self) -> int: + return (await self._get_positions_and_tachometer()).current_position # type: ignore + + async def get_tachometer(self) -> int: + """Current speed in rpm.""" + tack_to_rpm = -14.69320388 + return (await self._get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore + + async def get_home_position(self) -> int: + """Changes during a run, but the bucket 1 position relative to it does not.""" + return (await self._get_positions_and_tachometer()).home_position # type: ignore + + async def _get_status(self): + resp = await self._send_command(bytes.fromhex("aa020e10")) + if len(resp) == 0: + raise IOError("Empty status from centrifuge. Is the machine on?") + return resp + + async def get_bucket_locked(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0001 != 0 # type: ignore + + async def get_door_open(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0010 != 0 # type: ignore + + async def get_door_locked(self) -> bool: + resp = await self._get_status() + return resp[2] & 0b0100 == 0 # type: ignore + + # Centrifuge communication: read_resp, send + + async def _read_resp(self, timeout: float = 20) -> bytes: + data = b"" + end_byte_found = False + start_time = time.time() + + while True: + chunk = await self.io.read(25) + if chunk: + data += chunk + end_byte_found = data[-1] == 0x0D + if len(chunk) < 25 and end_byte_found: + break + else: + if end_byte_found or time.time() - start_time > timeout: + break + await asyncio.sleep(0.0001) + + logger.debug("Read %s", data.hex()) + return data + + async def _send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: + written = await self.io.write(bytes(cmd)) + + if written != len(cmd): + raise RuntimeError("Failed to write all bytes") + return await self._read_resp(timeout=read_timeout) + + async def configure_and_initialize(self): + await self.set_configuration_data() + await self.initialize() + + async def set_configuration_data(self): + """Set the device configuration data.""" + await self.io.set_latency_timer(16) + await self.io.set_line_property(bits=8, stopbits=1, parity=0) + await self.io.set_flowctrl(0) + await self.io.set_baudrate(19200) + + async def initialize(self): + await self.io.write(b"\x00" * 20) + for i in range(33): + packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 + await self.io.write(packet) + await self._send_command(bytes.fromhex("aaff0f0e")) + + # Centrifuge operations (CentrifugeBackend interface) + + async def open_door(self): + if await self.get_door_open(): + return + await self._send_command(bytes.fromhex("aa022600062e")) + await asyncio.sleep(4) + + async def close_door(self): + if not (await self.get_door_open()): + return + await self._send_command(bytes.fromhex("aa022600042c")) + await asyncio.sleep(2) + + async def lock_door(self): + if await self.get_door_open(): + raise RuntimeError("Cannot lock door while it is open.") + if await self.get_door_locked(): + return + await self._send_command(bytes.fromhex("aa0226000028")) + + async def unlock_door(self): + if not await self.get_door_locked(): + return + await self._send_command(bytes.fromhex("aa022600042c")) + + async def lock_bucket(self): + if await self.get_bucket_locked(): + return + await self._send_command(bytes.fromhex("aa022600072f")) + + async def unlock_bucket(self): + if not await self.get_bucket_locked(): + return + await self._send_command(bytes.fromhex("aa022600062e")) + + async def go_to_bucket1(self): + await self.go_to_position(await self.get_bucket_1_position()) + + async def go_to_bucket2(self): + await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) + + async def go_to_position(self, position: int): + await self.close_door() + await self.lock_door() + + position_bytes = position.to_bytes(4, byteorder="little") + byte_string = bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") + sum_byte = (sum(byte_string) - 0xAA) & 0xFF + byte_string += sum_byte.to_bytes(1, byteorder="little") + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(byte_string) + + while abs(await self.get_position() - position) > 10: + await asyncio.sleep(0.1) + await self.open_door() + + @staticmethod + def g_to_rpm(g: float) -> int: + r = 10 + rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) + return rpm + + @dataclass + class SpinParams(SerializableMixin): + acceleration: float = 0.8 + deceleration: float = 0.8 + + async def spin( + self, + g: float = 500, + duration: float = 60, + backend_params: Optional[SerializableMixin] = None, + ) -> None: + """Start a spin cycle. + + Args: + g: relative centrifugal force, also known as g-force + duration: time in seconds spent at speed (g) + backend_params: VSpinBackend.SpinParams with acceleration and deceleration (0-1). + """ + if not isinstance(backend_params, self.SpinParams): + backend_params = VSpinBackend.SpinParams() + + acceleration = backend_params.acceleration + deceleration = backend_params.deceleration + + if acceleration <= 0 or acceleration > 1: + raise ValueError("Acceleration must be within 0-1.") + if deceleration <= 0 or deceleration > 1: + raise ValueError("Deceleration must be within 0-1.") + if g < 1 or g > 1000: + raise ValueError("G-force must be within 1-1000") + if duration < 1: + raise ValueError("Spin time must be at least 1 second") + + if await self.get_door_open(): + await self.close_door() + if not await self.get_door_locked(): + await self.lock_door() + if await self.get_bucket_locked(): + await self.unlock_bucket() + + rpm = VSpinBackend.g_to_rpm(g) + + acceleration_ticks_per_second2 = 12903.2 * acceleration + rounds_per_second = rpm / 60 + ticks_per_second = rounds_per_second * 8000 + distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) + + distance_at_speed = ticks_per_second * duration + + current_position = await self.get_position() + final_position = int(current_position + distance_during_acceleration + distance_at_speed) + + if final_position > 2**32 - 1: + raise NotImplementedError( + "We don't know what happens if the destination position exceeds 2^32-1. " + "Please report this issue on discuss.pylabrobot.org." + ) + + position_b = final_position.to_bytes(4, byteorder="little") + rpm_b = int(rpm * 4473.925).to_bytes(4, byteorder="little") + acceleration_b = int(9.15 * 100 * acceleration).to_bytes(4, byteorder="little") + + byte_string = bytes.fromhex("aa01d497") + position_b + rpm_b + acceleration_b + checksum = (sum(byte_string) - 0xAA) & 0xFF + byte_string += checksum.to_bytes(1, byteorder="little") + + await self._send_command(bytes.fromhex("aa0226000028")) + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + + await self._send_command(byte_string) + + while await self.get_tachometer() < rpm * 0.95 and await self.get_position() < final_position: + await asyncio.sleep(0.1) + + if await self.get_position() < final_position: + decel_start_position = await self.get_position() + distance_at_speed + + while await self.get_position() < decel_start_position: + await asyncio.sleep(0.1) + + await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") + decel_command = bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("0000") + decel_command += ((sum(decel_command) - 0xAA) & 0xFF).to_bytes(1, byteorder="little") + await self._send_command(decel_command) + + await asyncio.sleep(2) + + async def _reset_to_zero(): + await self._send_command(bytes.fromhex("aa0117021a")) + await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._send_command(bytes.fromhex("aa0117041c")) + await self._send_command(bytes.fromhex("aa01170119")) + await self._send_command(bytes.fromhex("aa010b0c")) + await self._send_command(bytes.fromhex("aa010001")) + await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self._send_command(bytes.fromhex("aa01192842")) + + await _reset_to_zero() + + start = await self.get_home_position() + num_tries = 0 + while await self.get_home_position() == start: + await asyncio.sleep(0.1) + num_tries += 1 + if num_tries % 25 == 0: + await _reset_to_zero() + if num_tries > 100: + raise RuntimeError("Home position did not change after spin.") + + +class Access2Backend(DeviceBackend): + """Backend for the Agilent Access2 centrifuge loader.""" + + def __init__( + self, + device_id: str, + timeout: int = 60, + ): + """ + Args: + device_id: The libftdi id for the loader. Find using + `python3 -m pylibftdi.examples.list_devices` + """ + super().__init__() + self.io = FTDI(human_readable_device_name="Agilent Access2 Loader", device_id=device_id) + self.timeout = timeout + + async def _read(self) -> bytes: + x = b"" + r = None + start = time.time() + while r != b"" or x == b"": + r = await self.io.read(1) + x += r + if r == b"": + await asyncio.sleep(0.1) + if x == b"" and (time.time() - start) > self.timeout: + raise TimeoutError("No data received within the specified timeout period") + return x + + async def send_command(self, command: bytes) -> bytes: + logger.debug("[loader] Sending %s", command.hex()) + await self.io.write(command) + return await self._read() + + async def setup(self): + logger.debug("[loader] setup") + + await self.io.setup() + await self.io.set_baudrate(115384) + + status = await self.get_status() + if not status.startswith(bytes.fromhex("1105")): + raise RuntimeError("Failed to get status") + + await self.send_command(bytes.fromhex("110500030014000072b1")) + await self.send_command(bytes.fromhex("1105000300100000ae71")) + await self.send_command(bytes.fromhex("110500070024040000008000be89")) + await self.send_command(bytes.fromhex("11050007002404008000800063b1")) + await self.send_command(bytes.fromhex("11050007002404000001800089b9")) + await self.send_command(bytes.fromhex("1105000700240400800180005481")) + await self.send_command(bytes.fromhex("110500070024040000024000c6bd")) + await self.send_command(bytes.fromhex("1105000300400000f0bf")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + await self.send_command(bytes.fromhex("1105000e00440b00000000000000007041020203c7")) + + async def stop(self): + logger.debug("[loader] stop") + await self.io.stop() + + async def get_status(self) -> bytes: + logger.debug("[loader] get_status") + return await self.send_command(bytes.fromhex("11050003002000006bd4")) + + async def park(self): + logger.debug("[loader] park") + await self.send_command(bytes.fromhex("1105000e00440b0000000000410000704103007539")) + + async def close(self): + logger.debug("[loader] close") + await self.send_command(bytes.fromhex("1105000a00420700010000803f02008c64")) + + async def open(self): + logger.debug("[loader] open") + await self.send_command(bytes.fromhex("1105000a0042070001000080bf0200b73e")) + + async def load(self): + """Only tested for 1cm plate, 3mm pickup height.""" + logger.debug("[loader] load") + + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + await self.send_command(bytes.fromhex("1105000e00440b000100004040000020410200a5cb")) + + r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) + if r == bytes.fromhex("1105000800510500000300000079f1"): + raise RuntimeError("no plate found on stage") + + await self.send_command(bytes.fromhex("1105000a00460700018fc2b540020023dc")) + await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410300ee00")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) + await self.send_command(bytes.fromhex("1105000e00440b0000000040400000204102007d82")) + + async def unload(self): + """Only tested for 1cm plate, 3mm pickup height.""" + logger.debug("[loader] unload") + + await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) + await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410200dd31")) + + r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) + if r == bytes.fromhex("1105000800510500000300000079f1"): + raise RuntimeError("no plate found in centrifuge") + + await self.send_command(bytes.fromhex("1105000a00460700017b14b6400200d57a")) + await self.send_command(bytes.fromhex("1105000e00440b00010000404000002041030096fa")) + await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) + await self.send_command(bytes.fromhex("1105000e00440b00000000000000002041020056be")) + + +# --------------------------------------------------------------------------- +# Device +# --------------------------------------------------------------------------- + + +class VSpin(Resource, Device): + """Agilent VSpin Centrifuge.""" + + def __init__( + self, + name: str, + device_id: Optional[str] = None, + size_x: float = 0.0, + size_y: float = 0.0, + size_z: float = 0.0, + ): + backend = VSpinBackend(device_id=device_id) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Agilent VSpin", + category="centrifuge", + ) + Device.__init__(self, backend=backend) + self._backend: VSpinBackend = backend + + bucket1 = ResourceHolder( + name=f"{name}_bucket1", + size_x=127.76, + size_y=85.48, + size_z=0, + child_location=Coordinate.zero(), + ) + bucket2 = ResourceHolder( + name=f"{name}_bucket2", + size_x=127.76, + size_y=85.48, + size_z=0, + child_location=Coordinate.zero(), + ) + self.assign_child_resource(bucket1, location=Coordinate.zero()) + self.assign_child_resource(bucket2, location=Coordinate.zero()) + + self.centrifuging = CentrifugingCapability(backend=backend, buckets=(bucket1, bucket2)) + self._capabilities = [self.centrifuging] + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + +class Access2(ResourceHolder, Device): + """Agilent Access2 centrifuge loader.""" + + def __init__( + self, + name: str, + device_id: str, + vspin: VSpin, + size_x: float = 0.0, + size_y: float = 0.0, + size_z: float = 0.0, + ): + backend = Access2Backend(device_id=device_id) + ResourceHolder.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="Agilent Access2", + category="loader", + child_location=Coordinate.zero(), + ) + Device.__init__(self, backend=backend) + self._backend: Access2Backend = backend + self._vspin = vspin + + def serialize(self) -> dict: + return {**ResourceHolder.serialize(self), **Device.serialize(self)} + + async def load(self) -> None: + centrifuging = self._vspin.centrifuging + + if not centrifuging.door_open: + raise CentrifugeDoorError("Centrifuge door must be open to load a plate.") + if centrifuging.at_bucket is None: + raise NotAtBucketError( + "Centrifuge must be at a bucket to load a plate. " + "Use centrifuging.go_to_bucket1() or centrifuging.go_to_bucket2()." + ) + if self.resource is None: + raise LoaderNoPlateError("Loader must have a plate to load.") + if centrifuging.at_bucket.resource is not None: + raise BucketHasPlateError("Bucket must be empty to load a plate.") + + await self._backend.load() + + centrifuging.at_bucket.assign_child_resource(self.resource, location=Coordinate.zero()) + + async def unload(self) -> None: + centrifuging = self._vspin.centrifuging + + if not centrifuging.door_open: + raise CentrifugeDoorError("Centrifuge door must be open to unload a plate.") + if centrifuging.at_bucket is None: + raise NotAtBucketError( + "Centrifuge must be at a bucket to unload a plate. " + "Use centrifuging.go_to_bucket1() or centrifuging.go_to_bucket2()." + ) + if centrifuging.at_bucket.resource is None: + raise BucketNoPlateError("Bucket must have a plate to unload.") + + await self._backend.unload() + + self.assign_child_resource(centrifuging.at_bucket.resource) diff --git a/pylabrobot/capabilities/centrifuging/__init__.py b/pylabrobot/capabilities/centrifuging/__init__.py new file mode 100644 index 00000000000..153b48091b9 --- /dev/null +++ b/pylabrobot/capabilities/centrifuging/__init__.py @@ -0,0 +1,9 @@ +from .backend import CentrifugeBackend +from .centrifuging import CentrifugingCapability +from .errors import ( + BucketHasPlateError, + BucketNoPlateError, + CentrifugeDoorError, + LoaderNoPlateError, + NotAtBucketError, +) diff --git a/pylabrobot/capabilities/centrifuging/backend.py b/pylabrobot/capabilities/centrifuging/backend.py new file mode 100644 index 00000000000..e9f9159d518 --- /dev/null +++ b/pylabrobot/capabilities/centrifuging/backend.py @@ -0,0 +1,56 @@ +from abc import ABCMeta, abstractmethod +from typing import Optional + +from pylabrobot.device import DeviceBackend +from pylabrobot.serializer import SerializableMixin + + +class CentrifugeBackend(DeviceBackend, metaclass=ABCMeta): + """Abstract backend for centrifuge devices.""" + + @abstractmethod + async def open_door(self) -> None: + """Open the centrifuge door.""" + + @abstractmethod + async def close_door(self) -> None: + """Close the centrifuge door.""" + + @abstractmethod + async def lock_door(self) -> None: + """Lock the centrifuge door.""" + + @abstractmethod + async def unlock_door(self) -> None: + """Unlock the centrifuge door.""" + + @abstractmethod + async def go_to_bucket1(self) -> None: + """Rotate to bucket 1 position.""" + + @abstractmethod + async def go_to_bucket2(self) -> None: + """Rotate to bucket 2 position.""" + + @abstractmethod + async def lock_bucket(self) -> None: + """Lock the bucket.""" + + @abstractmethod + async def unlock_bucket(self) -> None: + """Unlock the bucket.""" + + @abstractmethod + async def spin( + self, + g: float, + duration: float, + backend_params: Optional[SerializableMixin] = None, + ) -> None: + """Start a spin cycle. + + Args: + g: The g-force to spin at. + duration: The duration of the spin in seconds (time at speed). + backend_params: Vendor-specific parameters. + """ diff --git a/pylabrobot/capabilities/centrifuging/centrifuging.py b/pylabrobot/capabilities/centrifuging/centrifuging.py new file mode 100644 index 00000000000..23686217e98 --- /dev/null +++ b/pylabrobot/capabilities/centrifuging/centrifuging.py @@ -0,0 +1,74 @@ +from typing import Optional, Tuple + +from pylabrobot.capabilities.capability import Capability +from pylabrobot.resources import ResourceHolder +from pylabrobot.serializer import SerializableMixin + +from .backend import CentrifugeBackend + + +class CentrifugingCapability(Capability): + """Centrifuging capability.""" + + def __init__( + self, + backend: CentrifugeBackend, + buckets: Tuple[ResourceHolder, ResourceHolder], + ): + super().__init__(backend=backend) + self.backend: CentrifugeBackend = backend + self._door_open = False + self._at_bucket: Optional[ResourceHolder] = None + self.bucket1, self.bucket2 = buckets + + async def open_door(self) -> None: + await self.backend.open_door() + self._door_open = True + + async def close_door(self) -> None: + await self.backend.close_door() + self._door_open = False + + @property + def door_open(self) -> bool: + return self._door_open + + async def lock_door(self) -> None: + await self.backend.lock_door() + + async def unlock_door(self) -> None: + await self.backend.unlock_door() + + async def lock_bucket(self) -> None: + await self.backend.lock_bucket() + + async def unlock_bucket(self) -> None: + await self.backend.unlock_bucket() + + async def go_to_bucket1(self) -> None: + await self.backend.go_to_bucket1() + self._at_bucket = self.bucket1 + + async def go_to_bucket2(self) -> None: + await self.backend.go_to_bucket2() + self._at_bucket = self.bucket2 + + async def spin( + self, + g: float, + duration: float, + backend_params: Optional[SerializableMixin] = None, + ) -> None: + """Start a spin cycle. + + Args: + g: The g-force to spin at. + duration: The duration of the spin in seconds (time at speed). + backend_params: Vendor-specific parameters. + """ + await self.backend.spin(g=g, duration=duration, backend_params=backend_params) + self._at_bucket = None + + @property + def at_bucket(self) -> Optional[ResourceHolder]: + return self._at_bucket diff --git a/pylabrobot/capabilities/centrifuging/errors.py b/pylabrobot/capabilities/centrifuging/errors.py new file mode 100644 index 00000000000..b1c28ea942d --- /dev/null +++ b/pylabrobot/capabilities/centrifuging/errors.py @@ -0,0 +1,18 @@ +class CentrifugeDoorError(Exception): + pass + + +class NotAtBucketError(Exception): + pass + + +class BucketNoPlateError(Exception): + pass + + +class BucketHasPlateError(Exception): + pass + + +class LoaderNoPlateError(Exception): + pass diff --git a/pylabrobot/legacy/centrifuge/standard.py b/pylabrobot/legacy/centrifuge/standard.py index 164730bb848..eb546eb0f79 100644 --- a/pylabrobot/legacy/centrifuge/standard.py +++ b/pylabrobot/legacy/centrifuge/standard.py @@ -1,18 +1,9 @@ -class LoaderNoPlateError(Exception): - pass - - -class CentrifugeDoorError(Exception): - pass - - -class NotAtBucketError(Exception): - pass - - -class BucketNoPlateError(Exception): - pass - - -class BucketHasPlateError(Exception): - pass +"""Legacy. Use pylabrobot.capabilities.centrifuging.errors instead.""" + +from pylabrobot.capabilities.centrifuging.errors import ( # noqa: F401 + BucketHasPlateError, + BucketNoPlateError, + CentrifugeDoorError, + LoaderNoPlateError, + NotAtBucketError, +) diff --git a/pylabrobot/legacy/centrifuge/vspin_backend.py b/pylabrobot/legacy/centrifuge/vspin_backend.py index 021a550f359..bd5dc6346b7 100644 --- a/pylabrobot/legacy/centrifuge/vspin_backend.py +++ b/pylabrobot/legacy/centrifuge/vspin_backend.py @@ -1,511 +1,164 @@ -import asyncio -import ctypes -import json +"""Legacy. Use pylabrobot.agilent.vspin instead.""" + import logging -import math -import os -import time -import warnings from typing import Optional -from pylabrobot.io.ftdi import FTDI - -from .backend import CentrifugeBackend, LoaderBackend -from .standard import LoaderNoPlateError +from pylabrobot.agilent.vspin import vspin as _new +from pylabrobot.legacy.centrifuge.backend import CentrifugeBackend, LoaderBackend +from pylabrobot.legacy.centrifuge.standard import LoaderNoPlateError logger = logging.getLogger(__name__) class Access2Backend(LoaderBackend): - def __init__( - self, - device_id: str, - timeout: int = 60, - ): - """ - Args: - device_id: The libftdi id for the loader. Find using - `python3 -m pylibftdi.examples.list_devices` - """ - self.io = FTDI(human_readable_device_name="Agilent Access2 Loader", device_id=device_id) - self.timeout = timeout - - async def _read(self) -> bytes: - x = b"" - r = None - start = time.time() - while r != b"" or x == b"": - r = await self.io.read(1) - x += r - if r == b"": - await asyncio.sleep(0.1) - if x == b"" and (time.time() - start) > self.timeout: - raise TimeoutError("No data received within the specified timeout period") - return x + """Legacy. Use pylabrobot.agilent.vspin.Access2Backend instead.""" - async def send_command(self, command: bytes) -> bytes: - logger.debug("[loader] Sending %s", command.hex()) - await self.io.write(command) - return await self._read() + def __init__(self, device_id: str, timeout: int = 60): + self._new = _new.Access2Backend(device_id=device_id, timeout=timeout) + + @property + def io(self): + return self._new.io + + @io.setter + def io(self, value): + self._new.io = value + + @property + def timeout(self): + return self._new.timeout + + @timeout.setter + def timeout(self, value): + self._new.timeout = value async def setup(self): - logger.debug("[loader] setup") - - await self.io.setup() - await self.io.set_baudrate(115384) - - status = await self.get_status() - if not status.startswith(bytes.fromhex("1105")): - raise RuntimeError("Failed to get status") - - await self.send_command(bytes.fromhex("110500030014000072b1")) - await self.send_command(bytes.fromhex("1105000300100000ae71")) - await self.send_command(bytes.fromhex("110500070024040000008000be89")) - await self.send_command(bytes.fromhex("11050007002404008000800063b1")) - await self.send_command(bytes.fromhex("11050007002404000001800089b9")) - await self.send_command(bytes.fromhex("1105000700240400800180005481")) - await self.send_command(bytes.fromhex("110500070024040000024000c6bd")) - await self.send_command(bytes.fromhex("1105000300400000f0bf")) - await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) - # await self.send_command(bytes.fromhex("11050003002000006bd4")) - await self.send_command(bytes.fromhex("1105000e00440b00000000000000007041020203c7")) - # await self.send_command(bytes.fromhex("11050003002000006bd4")) + await self._new.setup() async def stop(self): - logger.debug("[loader] stop") - await self.io.stop() + await self._new.stop() def serialize(self): return {"io": self.io.serialize(), "timeout": self.timeout} + async def send_command(self, command: bytes) -> bytes: + return await self._new.send_command(command) + async def get_status(self) -> bytes: - logger.debug("[loader] get_status") - return await self.send_command(bytes.fromhex("11050003002000006bd4")) + return await self._new.get_status() async def park(self): - logger.debug("[loader] park") - await self.send_command(bytes.fromhex("1105000e00440b0000000000410000704103007539")) + await self._new.park() async def close(self): - logger.debug("[loader] close") - await self.send_command(bytes.fromhex("1105000a00420700010000803f02008c64")) + await self._new.close() async def open(self): - logger.debug("[loader] open") - await self.send_command(bytes.fromhex("1105000a0042070001000080bf0200b73e")) + await self._new.open() async def load(self): - """only tested for 1cm plate, 3mm pickup height""" - logger.debug("[loader] load") - - await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) - await self.send_command(bytes.fromhex("1105000e00440b000100004040000020410200a5cb")) - - # laser check - r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) - if r == bytes.fromhex("1105000800510500000300000079f1"): - raise LoaderNoPlateError("no plate found on stage") - - await self.send_command(bytes.fromhex("1105000a00460700018fc2b540020023dc")) - await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410300ee00")) - await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) - await self.send_command(bytes.fromhex("1105000e00440b0000000040400000204102007d82")) + try: + await self._new.load() + except RuntimeError as e: + if "no plate found on stage" in str(e): + raise LoaderNoPlateError("no plate found on stage") from e + raise async def unload(self): - """only tested for 1cm plate, 3mm pickup height""" - logger.debug("[loader] unload") - - await self.send_command(bytes.fromhex("1105000a004607000100000000020235bf")) - await self.send_command(bytes.fromhex("1105000e00440b000200004040000020410200dd31")) - - # laser check - r = await self.send_command(bytes.fromhex("1105000300500000b3dc")) - if r == bytes.fromhex("1105000800510500000300000079f1"): - raise LoaderNoPlateError("no plate found in centrifuge") - - await self.send_command(bytes.fromhex("1105000a00460700017b14b6400200d57a")) - await self.send_command(bytes.fromhex("1105000e00440b00010000404000002041030096fa")) - await self.send_command(bytes.fromhex("1105000a004607000100000000020015fd")) - await self.send_command(bytes.fromhex("1105000e00440b00000000000000002041020056be")) - # await self.send_command(bytes.fromhex("11050003002000006bd4")) - - -_vspin_bucket_calibrations_path = os.path.join( - os.path.expanduser("~"), - ".pylabrobot", - "vspin_bucket_calibrations.json", -) - - -def _load_vspin_calibrations(device_id: str) -> Optional[int]: - if not os.path.exists(_vspin_bucket_calibrations_path): - warnings.warn( - f"No calibration found for VSpin with device id {device_id}. " - "Please set the bucket 1 position using `set_bucket_1_position_to_current` method after setup.", - UserWarning, - ) - return None - with open(_vspin_bucket_calibrations_path, "r") as f: - return json.load(f).get(device_id) # type: ignore - - -def _save_vspin_calibrations(device_id, remainder: int): - if os.path.exists(_vspin_bucket_calibrations_path): - with open(_vspin_bucket_calibrations_path, "r") as f: - data = json.load(f) - else: - data = {} - data[device_id] = remainder - os.makedirs(os.path.dirname(_vspin_bucket_calibrations_path), exist_ok=True) - with open(_vspin_bucket_calibrations_path, "w") as f: - json.dump(data, f) - - -FULL_ROTATION: int = 8000 - - -bucket_1_not_set_error = RuntimeError( - "Bucket 1 position not set. " - "Please rotate the bucket to bucket 1 using VSpinBackend.go_to_position and " - "then calling VSpinBackend.set_bucket_1_position_to_current." -) + try: + await self._new.unload() + except RuntimeError as e: + if "no plate found in centrifuge" in str(e): + raise LoaderNoPlateError("no plate found in centrifuge") from e + raise class VSpinBackend(CentrifugeBackend): - """Backend for the Agilent Centrifuge. - Note that this is not a complete implementation.""" + """Legacy. Use pylabrobot.agilent.vspin.VSpinBackend instead.""" def __init__(self, device_id: Optional[str] = None): - """ - Args: - device_id: The libftdi id for the centrifuge. Find using `python -m pylibftdi.examples.list_devices` - """ - self.io = FTDI(human_readable_device_name="Agilent VSpin Centrifuge", device_id=device_id) - self._bucket_1_remainder: Optional[int] = None - # only attempt loading calibration if device_id is not None - # if it is None, we will load it after setup when we can query the device id from the io - if device_id is not None: - self._bucket_1_remainder = _load_vspin_calibrations(device_id) + self._new = _new.VSpinBackend(device_id=device_id) - async def setup(self): - await self.io.setup() - # TODO: add functionality where if robot has been initialized before nothing needs to happen - for _ in range(3): - await self.configure_and_initialize() - await self._send_command(bytes.fromhex("aa002101ff21")) - await self._send_command(bytes.fromhex("aa002101ff21")) - await self._send_command(bytes.fromhex("aa01132034")) - await self._send_command(bytes.fromhex("aa002102ff22")) - await self._send_command(bytes.fromhex("aa02132035")) - await self._send_command(bytes.fromhex("aa002103ff23")) - await self._send_command(bytes.fromhex("aaff1a142d")) - - await self.io.set_baudrate(57600) - await self.io.set_rts(True) - await self.io.set_dtr(True) - - await self._send_command(bytes.fromhex("aa01121f32")) - for _ in range(8): - await self._send_command(bytes.fromhex("aa0220ff0f30")) - await self._send_command(bytes.fromhex("aa0220df0f10")) - await self._send_command(bytes.fromhex("aa0220df0e0f")) - await self._send_command(bytes.fromhex("aa0220df0c0d")) - await self._send_command(bytes.fromhex("aa0220df0809")) - for _ in range(4): - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa02120317")) - for _ in range(5): - await self._send_command(bytes.fromhex("aa0226200048")) - await self._send_command(bytes.fromhex("aa0226000028")) - await self.lock_door() - - await self._send_command(bytes.fromhex("aa0226000028")) - - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa010001")) - await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) - await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) - await self._send_command(bytes.fromhex("aa01192842")) - - resp = 0x89 - while resp == 0x89: - resp = (await self._get_positions_and_tachometer()).status - - # --- almost the same as go to position --- - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - new_position = (0).to_bytes(4, byteorder="little") # arbitrary - # rpm = 600, - # acceleration = 75.09289617486338 - await self._send_command( - bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") - ) - # ----------------------------------------- - - resp = 0x08 - while resp != 0x09: - resp = (await self._get_positions_and_tachometer()).status + @property + def io(self): + return self._new.io - await self._send_command(bytes.fromhex("aa0117021a")) + @io.setter + def io(self, value): + self._new.io = value - await self.lock_door() + @property + def _bucket_1_remainder(self): + return self._new._bucket_1_remainder - # If we have not set the calibration yet, load it now. - if self._bucket_1_remainder is None: - device_id = await self.io.get_serial() - self._bucket_1_remainder = _load_vspin_calibrations(device_id) + @_bucket_1_remainder.setter + def _bucket_1_remainder(self, value): + self._new._bucket_1_remainder = value @property def bucket_1_remainder(self) -> int: - if self._bucket_1_remainder is None: - raise bucket_1_not_set_error - return self._bucket_1_remainder + return self._new.bucket_1_remainder + + async def setup(self): + await self._new.setup() + + async def stop(self): + await self._new.stop() async def set_bucket_1_position_to_current(self) -> None: - """Set the current position as bucket 1 position and save calibration.""" - current_position = await self.get_position() - device_id = await self.io.get_serial() - remainder = await self.get_home_position() - current_position - self._bucket_1_remainder = current_position % FULL_ROTATION - _save_vspin_calibrations(device_id, remainder) + await self._new.set_bucket_1_position_to_current() async def get_bucket_1_position(self) -> int: - """Get the bucket 1 position based on calibration. - Normally it is the home position minus the remainder (calibration). - The bucket 1 position must be greater than the current position, so we find - the first position greater than the current position by adding full rotations if needed. - """ - if self._bucket_1_remainder is None: - raise bucket_1_not_set_error - home_position = await self.get_home_position() - bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder - # first number after current position that matches bucket 1 position mod FULL_ROTATION - current_position = await self.get_position() - bucket_1_position = ( - FULL_ROTATION - * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) - + bucket_1_position_mod_full_rotation - ) - return bucket_1_position - - async def stop(self): - await self.configure_and_initialize() - await self.io.stop() - - class _StatusPositionTachometer(ctypes.LittleEndianStructure): - _pack_ = 1 - _fields_ = [ - ("status", ctypes.c_uint8), - ("current_position", ctypes.c_uint32), - ("unknown1", ctypes.c_uint8), - ("tachometer", ctypes.c_int16), - ("unknown2", ctypes.c_uint8), - ("home_position", ctypes.c_uint32), - ("checksum", ctypes.c_uint8), - ] - - async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: - """Returns 14 bytes - - Example: - 11 22 25 00 00 4f 00 00 18 e0 05 00 00 a4 - ^^ checksum - ^^ ^^ ^^ ^^ home position - ^^ ? (probably binary status objects) - ^^ ^^ tachometer - ^^ ? (probably binary status objects) - ^^ ^^ ^^ ^^ current position - ^^ - - First byte (index 0): - - 11 = 0b0001011 = idle - - 13 = 0b0001101 = unknown - - 08 = 0b0001000 = spinning - - 09 = 0b0001001 = also spinning but different - - 19 = 0b0010011 = unknown - - 88 = 0b1011000 = unknown - - 89 = 0b1011001 = unknown - - 10th to 13th byte (index 9-12) = Homing Position - - Last byte (index 13) = checksum - """ - resp = await self._send_command(bytes.fromhex("aa010e0f")) - if len(resp) == 0: - raise IOError("Empty status from centrifuge") - return VSpinBackend._StatusPositionTachometer.from_buffer_copy(resp) + return await self._new.get_bucket_1_position() async def get_position(self) -> int: - return (await self._get_positions_and_tachometer()).current_position # type: ignore + return await self._new.get_position() async def get_tachometer(self) -> int: - """current speed in rpm""" - tack_to_rpm = -14.69320388 # R^2 = 0.9999 when spinning, but not specific at single-digit RPM - return (await self._get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore + return await self._new.get_tachometer() async def get_home_position(self) -> int: - """changes during a run, but the bucket 1 position relative to it does not""" - return (await self._get_positions_and_tachometer()).home_position # type: ignore - - async def _get_status(self): - """ - examples: - - 0080d0015 - - 0080f0015 - """ - - resp = await self._send_command(bytes.fromhex("aa020e10")) - if len(resp) == 0: - raise IOError("Empty status from centrifuge. Is the machine on?") - return resp + return await self._new.get_home_position() async def get_bucket_locked(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0001 != 0 # type: ignore + return await self._new.get_bucket_locked() async def get_door_open(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0010 != 0 # type: ignore + return await self._new.get_door_open() async def get_door_locked(self) -> bool: - resp = await self._get_status() - return resp[2] & 0b0100 == 0 # type: ignore - - # Centrifuge communication: read_resp, send - - async def _read_resp(self, timeout: float = 20) -> bytes: - """Read a response from the centrifuge. If the timeout is reached, return the data that has - been read so far.""" - data = b"" - end_byte_found = False - start_time = time.time() - - while True: - chunk = await self.io.read(25) - if chunk: - data += chunk - end_byte_found = data[-1] == 0x0D - if len(chunk) < 25 and end_byte_found: - break - else: - if end_byte_found or time.time() - start_time > timeout: - break - await asyncio.sleep(0.0001) - - logger.debug("Read %s", data.hex()) - return data - - async def _send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: - written = await self.io.write(bytes(cmd)) - - if written != len(cmd): - raise RuntimeError("Failed to write all bytes") - return await self._read_resp(timeout=read_timeout) - - async def configure_and_initialize(self): - await self.set_configuration_data() - await self.initialize() - - async def set_configuration_data(self): - """Set the device configuration data.""" - await self.io.set_latency_timer(16) - await self.io.set_line_property(bits=8, stopbits=1, parity=0) - await self.io.set_flowctrl(0) - await self.io.set_baudrate(19200) - - async def initialize(self): - await self.io.write(b"\x00" * 20) - for i in range(33): - packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 - await self.io.write(packet) - await self._send_command(bytes.fromhex("aaff0f0e")) - - # Centrifuge operations + return await self._new.get_door_locked() async def open_door(self): - if await self.get_door_open(): - return - # used to be: aa022600072f - await self._send_command(bytes.fromhex("aa022600062e")) # same as unlock door - - # we can't tell when the door is fully open, so we just wait a bit - await asyncio.sleep(4) + await self._new.open_door() async def close_door(self): - if not (await self.get_door_open()): - return - # used to be: aa022600052d - await self._send_command(bytes.fromhex("aa022600042c")) # same as unlock door - # we can't tell when the door is fully closed, so we just wait a bit - await asyncio.sleep(2) + await self._new.close_door() async def lock_door(self): - if await self.get_door_open(): - raise RuntimeError("Cannot lock door while it is open.") - if await self.get_door_locked(): - return - # used to be aa0226000129 - await self._send_command(bytes.fromhex("aa0226000028")) + await self._new.lock_door() async def unlock_door(self): - if not await self.get_door_locked(): - return - # used to be aa022600052d - await self._send_command(bytes.fromhex("aa022600042c")) # same as close door + await self._new.unlock_door() async def lock_bucket(self): - if await self.get_bucket_locked(): - return - await self._send_command(bytes.fromhex("aa022600072f")) + await self._new.lock_bucket() async def unlock_bucket(self): - if not await self.get_bucket_locked(): - return - await self._send_command(bytes.fromhex("aa022600062e")) # same as open door + await self._new.unlock_bucket() async def go_to_bucket1(self): - await self.go_to_position(await self.get_bucket_1_position()) + await self._new.go_to_bucket1() async def go_to_bucket2(self): - await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) + await self._new.go_to_bucket2() async def go_to_position(self, position: int): - await self.close_door() - await self.lock_door() - - position_bytes = position.to_bytes(4, byteorder="little") - byte_string = bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") - sum_byte = (sum(byte_string) - 0xAA) & 0xFF - byte_string += sum_byte.to_bytes(1, byteorder="little") - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(byte_string) - - # await self._send_command(bytes.fromhex("aa0117021a")) - while ( - abs(await self.get_position() - position) > 10 - ): # 10 tacks tolerance (10/8000 * 360 = 0.45 degrees) - await asyncio.sleep(0.1) - await self.open_door() + await self._new.go_to_position(position) @staticmethod def g_to_rpm(g: float) -> int: - # https://en.wikipedia.org/wiki/Centrifugation#Mathematical_formula - r = 10 - rpm = int((g / (1.118 * 10**-5 * r)) ** 0.5) - return rpm + return _new.VSpinBackend.g_to_rpm(g) async def spin( self, @@ -514,136 +167,19 @@ async def spin( acceleration: float = 0.8, deceleration: float = 0.8, ) -> None: - """Start a spin cycle. spin spin spin spin - - Args: - g: relative centrifugal force, also known as g-force - duration: time in seconds spent at speed (g) - acceleration: 0-1 of total acceleration - deceleration: 0-1 of total deceleration - """ - - if acceleration <= 0 or acceleration > 1: - raise ValueError("Acceleration must be within 0-1.") - if deceleration <= 0 or deceleration > 1: - raise ValueError("Deceleration must be within 0-1.") - if g < 1 or g > 1000: - raise ValueError("G-force must be within 1-1000") - if duration < 1: - raise ValueError("Spin time must be at least 1 second") - - if await self.get_door_open(): - await self.close_door() - if not await self.get_door_locked(): - await self.lock_door() - if await self.get_bucket_locked(): - await self.unlock_bucket() - - # 1 - compute the final position - rpm = VSpinBackend.g_to_rpm(g) - - # compute the distance traveled during the acceleration period - # distance = 1/2 * v^2 / a. area under 0 to t (triangle). t = a/v_max - # 12903.2 ticks/s^2 is 100% acceleration - acceleration_ticks_per_second2 = 12903.2 * acceleration - rounds_per_second = rpm / 60 - ticks_per_second = rounds_per_second * 8000 - distance_during_acceleration = int(0.5 * (ticks_per_second**2) / acceleration_ticks_per_second2) - - # compute the distance traveled at speed - distance_at_speed = ticks_per_second * duration - - current_position = await self.get_position() - final_position = int(current_position + distance_during_acceleration + distance_at_speed) - - if final_position > 2**32 - 1: - # this is almost 3 hours of spinning at 3000 rpm (max speed), - # so we assume nobody will ever hit this. - raise NotImplementedError( - "We don't know what happens if the destination position exceeds 2^32-1. " - "Please report this issue on discuss.pylabrobot.org." - ) - - # 2 - send "go to position" command with computed final position and rpm - position_b = final_position.to_bytes(4, byteorder="little") - rpm_b = int(rpm * 4473.925).to_bytes(4, byteorder="little") - acceleration_b = int(9.15 * 100 * acceleration).to_bytes(4, byteorder="little") - - byte_string = bytes.fromhex("aa01d497") + position_b + rpm_b + acceleration_b - checksum = (sum(byte_string) - 0xAA) & 0xFF - byte_string += checksum.to_bytes(1, byteorder="little") - - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) - - await self._send_command(byte_string) - - # 3 - wait for acceleration to the set rpm - # we also check the position to avoid waiting forever if the speed is not reached (e.g. short spin...) - while await self.get_tachometer() < rpm * 0.95 and await self.get_position() < final_position: - await asyncio.sleep(0.1) - - # 4 - once the speed is reached, compute the position at which to start deceleration - # this is different than computed above, because above we assumed constant acceleration from 0 to rpm. - # however, in reality there is jerk and the acceleration is not constant, so we have to adjust as we go. - # this is what the vendor software does too. - # if we are already past that position, we skip this part. - if await self.get_position() < final_position: - decel_start_position = await self.get_position() + distance_at_speed - - # then wait until we reach that position - while await self.get_position() < decel_start_position: - await asyncio.sleep(0.1) - - # 5 - send deceleration command - await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) - # aa0194b600000000dc02000029: decel at 80 - # aa0194b6000000000a03000058: decel at 85 - # aa0194b61283000012010000f3: used in setup (30%) - decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") - decel_command = bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("0000") - decel_command += ((sum(decel_command) - 0xAA) & 0xFF).to_bytes(1, byteorder="little") - await self._send_command(decel_command) - - await asyncio.sleep(2) - - # 6 - reset position back to 0ish - # this part is aneeded because otherwise calling go_to_position will not work after - async def _reset_to_zero(): - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa010001")) # set position back to 0 (exactly) - await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) - await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) - await self._send_command(bytes.fromhex("aa01192842")) # it starts moving again - - await _reset_to_zero() - - # 7 - wait for home position to change - # go_to_bucket{1,2} does not work until the home position changes - start = await self.get_home_position() - num_tries = 0 - while await self.get_home_position() == start: - await asyncio.sleep(0.1) - num_tries += 1 - if num_tries % 25 == 0: - await _reset_to_zero() - if num_tries > 100: - raise RuntimeError("Home position did not change after spin.") - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 + await self._new.spin( + g=g, + duration=duration, + backend_params=_new.VSpinBackend.SpinParams( + acceleration=acceleration, deceleration=deceleration + ), + ) + + async def configure_and_initialize(self): + await self._new.configure_and_initialize() +# Deprecated alias class VSpin: def __init__(self, *args, **kwargs): raise RuntimeError("`VSpin` is deprecated. Please use `VSpinBackend` instead. ") From da085e90cb511675780fd8b251016490f05d8bb4 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 23 Mar 2026 14:36:35 -0700 Subject: [PATCH 04/69] Add Opentrons temperature module to new architecture (#955) y: Claude Opus 4.6 (1M context) --- .../opentrons_backend.py | 48 ++----- .../opentrons_backend_usb.py | 77 ++-------- pylabrobot/opentrons/__init__.py | 5 + .../opentrons/temperature_module/__init__.py | 2 + .../opentrons/temperature_module/backend.py | 136 ++++++++++++++++++ .../temperature_module/temperature_module.py | 79 ++++++++++ 6 files changed, 248 insertions(+), 99 deletions(-) create mode 100644 pylabrobot/opentrons/__init__.py create mode 100644 pylabrobot/opentrons/temperature_module/__init__.py create mode 100644 pylabrobot/opentrons/temperature_module/backend.py create mode 100644 pylabrobot/opentrons/temperature_module/temperature_module.py diff --git a/pylabrobot/legacy/temperature_controlling/opentrons_backend.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py index c1d4571a28a..79e7f199f8c 100644 --- a/pylabrobot/legacy/temperature_controlling/opentrons_backend.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py @@ -1,60 +1,38 @@ -from typing import cast +"""Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleBackend instead.""" from pylabrobot.legacy.temperature_controlling.backend import ( TemperatureControllerBackend, ) - -try: - import ot_api - - USE_OT = True -except ImportError as e: - USE_OT = False - _OT_IMPORT_ERROR = e +from pylabrobot.opentrons.temperature_module import ( + OpentronsTemperatureModuleBackend as _NewBackend, +) class OpentronsTemperatureModuleBackend(TemperatureControllerBackend): - """Opentrons temperature module backend.""" + """Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleBackend instead.""" @property def supports_active_cooling(self) -> bool: - return False + return self._new.supports_active_cooling def __init__(self, opentrons_id: str): - """Create a new Opentrons temperature module backend. - - Args: - opentrons_id: Opentrons ID of the temperature module. Get it from - `OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()`. - """ + self._new = _NewBackend(opentrons_id=opentrons_id) self.opentrons_id = opentrons_id - if not USE_OT: - raise RuntimeError( - "Opentrons is not installed. Please run pip install pylabrobot[opentrons]." - f" Import error: {_OT_IMPORT_ERROR}." - ) - async def setup(self): - pass + await self._new.setup() async def stop(self): - await self.deactivate() + await self._new.stop() def serialize(self) -> dict: - return {**super().serialize(), "opentrons_id": self.opentrons_id} + return self._new.serialize() async def set_temperature(self, temperature: float): - ot_api.modules.temperature_module_set_temperature( - celsius=temperature, module_id=self.opentrons_id - ) + await self._new.set_temperature(temperature) async def deactivate(self): - ot_api.modules.temperature_module_deactivate(module_id=self.opentrons_id) + await self._new.deactivate() async def get_current_temperature(self) -> float: - modules = ot_api.modules.list_connected_modules() - for module in modules: - if module["id"] == self.opentrons_id: - return cast(float, module["data"]["currentTemperature"]) - raise RuntimeError(f"Module with id '{self.opentrons_id}' not found") + return await self._new.get_current_temperature() diff --git a/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py index 2616020cf1a..760b9d3ce38 100644 --- a/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py @@ -1,89 +1,38 @@ -from typing import Optional +"""Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleUSBBackend instead.""" -from pylabrobot.io.serial import Serial from pylabrobot.legacy.temperature_controlling.backend import ( TemperatureControllerBackend, ) +from pylabrobot.opentrons.temperature_module import ( + OpentronsTemperatureModuleUSBBackend as _NewBackend, +) class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend): - """Opentrons temperature module backend.""" + """Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleUSBBackend instead.""" @property def supports_active_cooling(self) -> bool: - return True + return self._new.supports_active_cooling def __init__(self, port: str): - """Create a new Opentrons temperature module backend. - - Args: - port: Serial port for USB communication. - """ - + self._new = _NewBackend(port=port) self.port = port - self._serial: Optional["Serial"] = None - - @property - def serial(self) -> "Serial": - if self._serial is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - return self._serial async def setup(self): - # Setup serial communication for USB - self._serial = Serial( - human_readable_device_name="Opentrons Temperature Module", - port=self.port, - baudrate=115200, - timeout=3, - ) - await self._serial.setup() + await self._new.setup() async def stop(self): - await self.deactivate() - if self._serial is not None: - await self._serial.stop() - self._serial = None + await self._new.stop() def serialize(self) -> dict: - return {**super().serialize(), "port": self.port} + return self._new.serialize() async def set_temperature(self, temperature: float): - tmp_message = f"M104 S{temperature}\r\n" - await self.serial.write(tmp_message.encode("utf-8")) - # Read response (should be "ok\r\nok\r\n") - response1 = await self.serial.readline() - response2 = await self.serial.readline() - # Verify we got the expected response - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}" - ) + await self._new.set_temperature(temperature) async def deactivate(self): - await self.serial.write(b"M18\r\n") - # Read response (should be "ok\r\nok\r\n") - response1 = await self.serial.readline() - response2 = await self.serial.readline() - # Verify we got the expected response - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}" - ) + await self._new.deactivate() async def get_current_temperature(self) -> float: - await self.serial.write(b"M105\r\n") - # Read response (should be "T:XX.XXX C:XX.XXX\r\nok\r\nok\r\n") - response = await self.serial.readline() - # Verify we got the expected response - # Read response (should be "ok\r\nok\r\n") - if b"C" not in response: - raise ValueError(f"Unexpected response from device: {response.decode(encoding='utf-8')}") - - response1 = await self.serial.readline() - response2 = await self.serial.readline() - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}" - ) - return float(response.strip().split(b"C:")[-1]) + return await self._new.get_current_temperature() diff --git a/pylabrobot/opentrons/__init__.py b/pylabrobot/opentrons/__init__.py new file mode 100644 index 00000000000..cb725b8a7b3 --- /dev/null +++ b/pylabrobot/opentrons/__init__.py @@ -0,0 +1,5 @@ +from .temperature_module import ( + OpentronsTemperatureModuleBackend, + OpentronsTemperatureModuleUSBBackend, + OpentronsTemperatureModuleV2, +) diff --git a/pylabrobot/opentrons/temperature_module/__init__.py b/pylabrobot/opentrons/temperature_module/__init__.py new file mode 100644 index 00000000000..964582e9e3e --- /dev/null +++ b/pylabrobot/opentrons/temperature_module/__init__.py @@ -0,0 +1,2 @@ +from .backend import OpentronsTemperatureModuleBackend, OpentronsTemperatureModuleUSBBackend +from .temperature_module import OpentronsTemperatureModuleV2 diff --git a/pylabrobot/opentrons/temperature_module/backend.py b/pylabrobot/opentrons/temperature_module/backend.py new file mode 100644 index 00000000000..73b3aaabe82 --- /dev/null +++ b/pylabrobot/opentrons/temperature_module/backend.py @@ -0,0 +1,136 @@ +from typing import Optional, cast + +from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend +from pylabrobot.io.serial import Serial + +try: + import ot_api + + USE_OT = True +except ImportError as e: + USE_OT = False + _OT_IMPORT_ERROR = e + + +class OpentronsTemperatureModuleBackend(TemperatureControllerBackend): + """Backend for the Opentrons Temperature Module v2 via the Opentrons HTTP API.""" + + @property + def supports_active_cooling(self) -> bool: + return False + + def __init__(self, opentrons_id: str): + """Create a new Opentrons temperature module backend. + + Args: + opentrons_id: Opentrons ID of the temperature module. Get it from + ``OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()``. + """ + self.opentrons_id = opentrons_id + + if not USE_OT: + raise RuntimeError( + "Opentrons is not installed. Please run pip install pylabrobot[opentrons]." + f" Import error: {_OT_IMPORT_ERROR}." + ) + + async def setup(self): + pass + + async def stop(self): + await self.deactivate() + + def serialize(self) -> dict: + return {**super().serialize(), "opentrons_id": self.opentrons_id} + + async def set_temperature(self, temperature: float): + ot_api.modules.temperature_module_set_temperature( + celsius=temperature, module_id=self.opentrons_id + ) + + async def deactivate(self): + ot_api.modules.temperature_module_deactivate(module_id=self.opentrons_id) + + async def get_current_temperature(self) -> float: + modules = ot_api.modules.list_connected_modules() + for module in modules: + if module["id"] == self.opentrons_id: + return cast(float, module["data"]["currentTemperature"]) + raise RuntimeError(f"Module with id '{self.opentrons_id}' not found") + + +class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend): + """Backend for the Opentrons Temperature Module v2 via direct USB serial.""" + + @property + def supports_active_cooling(self) -> bool: + return True + + def __init__(self, port: str): + """Create a new Opentrons temperature module USB backend. + + Args: + port: Serial port for USB communication. + """ + self.port = port + self._serial: Optional[Serial] = None + + @property + def serial(self) -> Serial: + if self._serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + return self._serial + + async def setup(self): + self._serial = Serial( + human_readable_device_name="Opentrons Temperature Module", + port=self.port, + baudrate=115200, + timeout=3, + ) + await self._serial.setup() + + async def stop(self): + await self.deactivate() + if self._serial is not None: + await self._serial.stop() + self._serial = None + + def serialize(self) -> dict: + return {**super().serialize(), "port": self.port} + + async def set_temperature(self, temperature: float): + tmp_message = f"M104 S{temperature}\r\n" + await self.serial.write(tmp_message.encode("utf-8")) + response1 = await self.serial.readline() + response2 = await self.serial.readline() + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError( + f"Unexpected response from device: {response1.decode(encoding='utf-8')} " + f"{response2.decode(encoding='utf-8')}" + ) + + async def deactivate(self): + await self.serial.write(b"M18\r\n") + response1 = await self.serial.readline() + response2 = await self.serial.readline() + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError( + f"Unexpected response from device: {response1.decode(encoding='utf-8')} " + f"{response2.decode(encoding='utf-8')}" + ) + + async def get_current_temperature(self) -> float: + await self.serial.write(b"M105\r\n") + response = await self.serial.readline() + if b"C" not in response: + raise ValueError(f"Unexpected response from device: {response.decode(encoding='utf-8')}") + + response1 = await self.serial.readline() + response2 = await self.serial.readline() + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError( + f"Unexpected response from device: {response1.decode(encoding='utf-8')} " + f"{response2.decode(encoding='utf-8')}" + ) + return float(response.strip().split(b"C:")[-1]) diff --git a/pylabrobot/opentrons/temperature_module/temperature_module.py b/pylabrobot/opentrons/temperature_module/temperature_module.py new file mode 100644 index 00000000000..924fc609795 --- /dev/null +++ b/pylabrobot/opentrons/temperature_module/temperature_module.py @@ -0,0 +1,79 @@ +from typing import Optional + +from pylabrobot.capabilities.temperature_controlling import ( + TemperatureControlCapability, + TemperatureControllerBackend, +) +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, ItemizedResource, ResourceHolder +from pylabrobot.resources.opentrons.module import OTModule + +from .backend import OpentronsTemperatureModuleBackend, OpentronsTemperatureModuleUSBBackend + + +class OpentronsTemperatureModuleV2(ResourceHolder, Device, OTModule): + """Opentrons Temperature Module v2. + + https://opentrons.com/products/modules/temperature/ + https://shop.opentrons.com/aluminum-block-set/ + + Example: + >>> from pylabrobot.opentrons.temperature_module import OpentronsTemperatureModuleV2 + >>> mod = OpentronsTemperatureModuleV2("temp_mod", serial_port="/dev/ttyACM0") + >>> await mod.setup() + >>> await mod.tc.set_temperature(37.0) + >>> await mod.tc.get_temperature() + 37.0 + """ + + def __init__( + self, + name: str, + opentrons_id: Optional[str] = None, + serial_port: Optional[str] = None, + child_location: Coordinate = Coordinate(0, 0, 80.1), + child: Optional[ItemizedResource] = None, + ): + """Create a new Opentrons Temperature Module v2. + + Args: + name: Name of the temperature module. + opentrons_id: Opentrons ID of the temperature module. Exactly one of + ``opentrons_id`` or ``serial_port`` must be provided. + serial_port: Serial port for USB communication. Exactly one of + ``opentrons_id`` or ``serial_port`` must be provided. + child_location: Location of the child resource relative to this module. + child: Optional child resource like a tube rack or well plate. + """ + if opentrons_id is None and serial_port is None: + raise ValueError("Exactly one of `opentrons_id` or `serial_port` must be provided.") + if opentrons_id is not None and serial_port is not None: + raise ValueError("Exactly one of `opentrons_id` or `serial_port` must be provided.") + + backend: TemperatureControllerBackend + if serial_port is not None: + backend = OpentronsTemperatureModuleUSBBackend(port=serial_port) + else: + assert opentrons_id is not None + backend = OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id) + + ResourceHolder.__init__( + self, + name=name, + size_x=193.5, + size_y=89.2, + size_z=84.0, + child_location=child_location, + category="temperature_controller", + model="temperatureModuleV2", + ) + Device.__init__(self, backend=backend) + self._backend = backend + self.tc = TemperatureControlCapability(backend=backend) + self._capabilities = [self.tc] + + if child is not None: + self.assign_child_resource(child) + + def serialize(self) -> dict: + return {**ResourceHolder.serialize(self), **Device.serialize(self)} From 4beca0dba5bea5235f924c0dee4b1362d86f7575 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 24 Mar 2026 22:24:27 -0700 Subject: [PATCH 05/69] add P-DW-10ML-24-C part number --- docs/resources/library/corning.md | 4 ++-- pylabrobot/resources/corning/axygen/plates.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/resources/library/corning.md b/docs/resources/library/corning.md index 1248f83c2ef..30008ffd934 100644 --- a/docs/resources/library/corning.md +++ b/docs/resources/library/corning.md @@ -43,8 +43,8 @@ Company page: [Corning - Axygen® Brand Products](https://www.corning.com/emea/e | Description | Image | PLR definition | |-|-|-| -| 'Cor_Axy_24_wellplate_10mL_Vb'
Part no.: P-DW-10ML-24-C-S
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2b/UK/en/Genomics-&-Molecular-Biology/Automation-Consumables/Deep-Well-Plate/Axygen%C2%AE-Deep-Well-and-Assay-Plates/p/P-DW-10ML-24-C-S) | ![](img/corning_axygen/Cor_Axy_24_wellplate_10mL_Vb.jpg) | `Cor_Axy_24_wellplate_10mL_Vb` | -| 'Cor_Axy_96_wellplate_500uL_Ub'
Part no.: P-96-450V-C-S ("-S" indicates sterile labware)
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2c/US/en/Genomics-&-Molecular-Biology/Automation-Consumables/Deep-Well-Plate/Axygen%C2%AE-Deep-Well-and-Assay-Plates/p/P-96-450V-C-S) | ![](img/corning_axygen/Cor_Axy_96_wellplate_500uL_Ub.png) | `Cor_Axy_96_wellplate_500uL_Ub` | +| 'Cor_Axy_24_wellplate_10mL_Vb'
Part no.: P-DW-10ML-24-C-S, P-DW-10ML-24-C
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2b/UK/en/Genomics-&-Molecular-Biology/Automation-Consumables/Deep-Well-Plate/Axygen%C2%AE-Deep-Well-and-Assay-Plates/p/P-DW-10ML-24-C-S) | ![](img/corning_axygen/Cor_Axy_24_wellplate_10mL_Vb.jpg) | `Cor_Axy_24_wellplate_10mL_Vb` | +| 'Cor_Axy_96_wellplate_500uL_Ub'
Part no.: P-96-450V-C-S, P-96-450V-C ("-S" indicates sterile labware)
[manufacturer website](https://ecatalog.corning.com/life-sciences/b2c/US/en/Genomics-&-Molecular-Biology/Automation-Consumables/Deep-Well-Plate/Axygen%C2%AE-Deep-Well-and-Assay-Plates/p/P-96-450V-C-S) | ![](img/corning_axygen/Cor_Axy_96_wellplate_500uL_Ub.png) | `Cor_Axy_96_wellplate_500uL_Ub` | ## Corning - Costar diff --git a/pylabrobot/resources/corning/axygen/plates.py b/pylabrobot/resources/corning/axygen/plates.py index cd09709aaec..c599a5d0fd7 100644 --- a/pylabrobot/resources/corning/axygen/plates.py +++ b/pylabrobot/resources/corning/axygen/plates.py @@ -14,7 +14,7 @@ def Cor_Axy_24_wellplate_10mL_Vb(name: str, with_lid: bool = False) -> Plate: """ - Corning cat. no.: P-DW-10ML-24-C-S + Corning cat. no.: P-DW-10ML-24-C-S, P-DW-10ML-24-C - manufacturer_link: https://ecatalog.corning.com/life-sciences/b2b/UK/en/Genomics-&-Molecular-Biology/Automation-Consumables/Deep-Well-Plate/Axygen%C2%AE-Deep-Well-and-Assay-Plates/p/P-DW-10ML-24-C - brand: Axygen - distributor: (Fisher Scientific, 12557837) From 67ec8b4a84e96bd980a19118ebabab901205c9a0 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Wed, 25 Mar 2026 15:23:15 -0700 Subject: [PATCH 06/69] Add BackendParams base class with autoreload-safe isinstance Notebook autoreload creates new class objects, breaking isinstance checks on backend params (silently falling back to defaults). BackendParams uses a metaclass with __instancecheck__ that falls back to qualname+module comparison, which stays stable across reloads. Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/agilent/biotek/biotek.py | 3 ++- pylabrobot/agilent/biotek/cytation.py | 3 ++- pylabrobot/agilent/vspin/vspin.py | 3 ++- pylabrobot/azenta/xpeel.py | 3 ++- pylabrobot/bmg_labtech/clariostar.py | 3 ++- pylabrobot/byonoy/luminescence_96.py | 3 ++- pylabrobot/capabilities/capability.py | 22 +++++++++++++++++++ .../molecular_devices/spectramax/backend.py | 3 ++- .../spectramax/spectramax_m5.py | 5 +++-- 9 files changed, 39 insertions(+), 9 deletions(-) diff --git a/pylabrobot/agilent/biotek/biotek.py b/pylabrobot/agilent/biotek/biotek.py index 406460beb5a..9c512f243e1 100644 --- a/pylabrobot/agilent/biotek/biotek.py +++ b/pylabrobot/agilent/biotek/biotek.py @@ -17,6 +17,7 @@ ) from pylabrobot.io.ftdi import FTDI from pylabrobot.resources import Plate, Well +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin logger = logging.getLogger(__name__) @@ -367,7 +368,7 @@ async def read_absorbance( ] @dataclass - class LuminescenceParams(SerializableMixin): + class LuminescenceParams(BackendParams): integration_time: float = 1 async def read_luminescence( diff --git a/pylabrobot/agilent/biotek/cytation.py b/pylabrobot/agilent/biotek/cytation.py index 40d0e89c1f5..2b5d1f3f687 100644 --- a/pylabrobot/agilent/biotek/cytation.py +++ b/pylabrobot/agilent/biotek/cytation.py @@ -26,6 +26,7 @@ from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability from pylabrobot.device import Device from pylabrobot.resources import Coordinate, Plate, PlateHolder, Resource +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin try: @@ -747,7 +748,7 @@ async def _acquire_image( raise TimeoutError("max_image_read_attempts reached") @dataclass - class CaptureParams(SerializableMixin): + class CaptureParams(BackendParams): led_intensity: int = 10 coverage: Union[Literal["full"], Tuple[int, int]] = (1, 1) center_position: Optional[Tuple[float, float]] = None diff --git a/pylabrobot/agilent/vspin/vspin.py b/pylabrobot/agilent/vspin/vspin.py index 4a6a01bc85f..b253f973246 100644 --- a/pylabrobot/agilent/vspin/vspin.py +++ b/pylabrobot/agilent/vspin/vspin.py @@ -21,6 +21,7 @@ from pylabrobot.device import Device, DeviceBackend from pylabrobot.io.ftdi import FTDI from pylabrobot.resources import Coordinate, Resource, ResourceHolder +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin logger = logging.getLogger(__name__) @@ -354,7 +355,7 @@ def g_to_rpm(g: float) -> int: return rpm @dataclass - class SpinParams(SerializableMixin): + class SpinParams(BackendParams): acceleration: float = 0.8 deceleration: float = 0.8 diff --git a/pylabrobot/azenta/xpeel.py b/pylabrobot/azenta/xpeel.py index c3415b0aaf5..d9b5af60ec9 100644 --- a/pylabrobot/azenta/xpeel.py +++ b/pylabrobot/azenta/xpeel.py @@ -14,6 +14,7 @@ from pylabrobot.capabilities.peeling import PeelerBackend, PeelingCapability from pylabrobot.device import Device from pylabrobot.io.serial import Serial +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin @@ -158,7 +159,7 @@ async def restart(self, backend_params: Optional[SerializableMixin] = None): return await self._send_command("*restart", expect_ack=True, wait_for_ready=True) @dataclass - class PeelParams(SerializableMixin): + class PeelParams(BackendParams): begin_location: Literal[-2, 0, 2, 4] = 0 fast: bool = False adhere_time: float = 2.5 diff --git a/pylabrobot/bmg_labtech/clariostar.py b/pylabrobot/bmg_labtech/clariostar.py index bdf957606ea..10e89e0c690 100644 --- a/pylabrobot/bmg_labtech/clariostar.py +++ b/pylabrobot/bmg_labtech/clariostar.py @@ -27,6 +27,7 @@ from pylabrobot.resources import Coordinate, PlateHolder, Resource from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin from pylabrobot.utils.list import reshape_2d @@ -305,7 +306,7 @@ async def read_luminescence( ] @dataclass - class AbsorbanceParams(SerializableMixin): + class AbsorbanceParams(BackendParams): report: Literal["OD", "transmittance"] = "OD" async def read_absorbance( diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index 71a198b3c52..bd188a24db8 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -15,6 +15,7 @@ from pylabrobot.resources.plate import Plate from pylabrobot.resources.rotation import Rotation from pylabrobot.resources.well import Well +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin from pylabrobot.utils.list import reshape_2d @@ -30,7 +31,7 @@ def __init__(self) -> None: super().__init__(pid=0x119B, device_type=ByonoyDevice.LUMINESCENCE_96) @dataclass - class LuminescenceParams(SerializableMixin): + class LuminescenceParams(BackendParams): integration_time: float = 2 async def read_luminescence( diff --git a/pylabrobot/capabilities/capability.py b/pylabrobot/capabilities/capability.py index fd51ed3bbba..7c2550ebbcc 100644 --- a/pylabrobot/capabilities/capability.py +++ b/pylabrobot/capabilities/capability.py @@ -6,6 +6,7 @@ from typing import Any, Awaitable, Callable, TypeVar from pylabrobot.device import DeviceBackend +from pylabrobot.serializer import SerializableMixin if sys.version_info < (3, 10): from typing_extensions import ParamSpec @@ -37,6 +38,27 @@ async def wrapper(*args, **kwargs): return wrapper +class _BackendParamsMeta(type): + """Metaclass that makes isinstance checks survive notebook autoreload. + + After autoreload, class objects are recreated so old instances fail normal + isinstance checks. This falls back to comparing the qualified class name + and module, which stay stable across reloads. + """ + + def __instancecheck__(cls, instance): + if super().__instancecheck__(instance): + return True + return ( + type(instance).__qualname__ == cls.__qualname__ + and type(instance).__module__ == cls.__module__ + ) + + +class BackendParams(SerializableMixin, metaclass=_BackendParamsMeta): + """Base class for backend-specific parameter dataclasses.""" + + class Capability(ABC): """Base class for device capabilities. diff --git a/pylabrobot/molecular_devices/spectramax/backend.py b/pylabrobot/molecular_devices/spectramax/backend.py index 42f6dee8c0e..22721d85952 100644 --- a/pylabrobot/molecular_devices/spectramax/backend.py +++ b/pylabrobot/molecular_devices/spectramax/backend.py @@ -12,6 +12,7 @@ from pylabrobot.io.serial import Serial from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin logger = logging.getLogger("pylabrobot") @@ -712,7 +713,7 @@ async def _wait_for_idle(self, timeout: int = 600): await asyncio.sleep(1) @dataclass - class AbsorbanceParams(SerializableMixin): + class AbsorbanceParams(BackendParams): wavelengths: Optional[List[Union[int, Tuple[int, bool]]]] = None read_type: ReadType = ReadType.ENDPOINT read_order: ReadOrder = ReadOrder.COLUMN diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py index fb2d4dc4dcd..ef33fd174e1 100644 --- a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py +++ b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py @@ -13,6 +13,7 @@ from pylabrobot.resources import Coordinate, PlateHolder, Resource from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin from .backend import ( @@ -41,7 +42,7 @@ def __init__(self, port: str) -> None: super().__init__(port, human_readable_device_name="Molecular Devices SpectraMax M5") @dataclass - class FluorescenceParams(SerializableMixin): + class FluorescenceParams(BackendParams): excitation_wavelengths: Optional[List[int]] = None emission_wavelengths: Optional[List[int]] = None cutoff_filters: Optional[List[int]] = None @@ -131,7 +132,7 @@ async def read_fluorescence( ] @dataclass - class LuminescenceParams(SerializableMixin): + class LuminescenceParams(BackendParams): emission_wavelengths: Optional[List[int]] = None read_type: ReadType = ReadType.ENDPOINT read_order: ReadOrder = ReadOrder.COLUMN From a05123730a070f7cab16577599d2c42749cfa89e Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Wed, 25 Mar 2026 22:20:48 -0700 Subject: [PATCH 07/69] Rename DeviceBackend to Driver, introduce CapabilityBackend - DeviceBackend -> Driver (with backward-compat alias) - Device._backend -> Device._driver, param backend -> driver - New CapabilityBackend ABC for capability-specific backend interfaces - All 15 abstract capability backends now extend CapabilityBackend - Concrete backends extend both their capability backend and Driver - Serialization key "backend" -> "driver" (deserialize accepts both) Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/agilent/biotek/biotek.py | 5 +- pylabrobot/agilent/biotek/cytation.py | 16 ++--- pylabrobot/agilent/biotek/synergy_h1.py | 8 +-- pylabrobot/agilent/vspin/vspin.py | 18 ++--- pylabrobot/azenta/a4s.py | 18 ++--- pylabrobot/azenta/xpeel.py | 8 +-- pylabrobot/bmg_labtech/clariostar.py | 12 ++-- pylabrobot/byonoy/absorbance_96.py | 4 +- pylabrobot/byonoy/backend.py | 4 +- pylabrobot/byonoy/luminescence_96.py | 4 +- pylabrobot/capabilities/__init__.py | 2 +- .../automated_retrieval/backend.py | 4 +- .../capabilities/barcode_scanning/backend.py | 4 +- pylabrobot/capabilities/capability.py | 16 +++-- .../capabilities/centrifuging/backend.py | 4 +- .../capabilities/fan_control/backend.py | 4 +- .../humidity_controlling/backend.py | 4 +- pylabrobot/capabilities/microscopy/backend.py | 4 +- .../capabilities/microscopy/chatterbox.py | 3 +- .../microscopy/microscopy_tests.py | 16 ++--- pylabrobot/capabilities/peeling/backend.py | 4 +- .../absorbance/absorbance_tests.py | 16 ++--- .../plate_reading/absorbance/backend.py | 4 +- .../plate_reading/absorbance/chatterbox.py | 3 +- .../plate_reading/fluorescence/backend.py | 4 +- .../plate_reading/fluorescence/chatterbox.py | 3 +- .../fluorescence/fluorescence_tests.py | 14 ++-- .../plate_reading/luminescence/backend.py | 4 +- .../plate_reading/luminescence/chatterbox.py | 3 +- .../luminescence/luminescence_tests.py | 14 ++-- pylabrobot/capabilities/sealing/backend.py | 4 +- pylabrobot/capabilities/shaking/backend.py | 4 +- .../temperature_controlling/backend.py | 4 +- pylabrobot/capabilities/tilting/backend.py | 4 +- pylabrobot/capabilities/weighing/backend.py | 4 +- pylabrobot/device.py | 27 +++---- pylabrobot/hamilton/heater_shaker/backend.py | 10 +-- .../hamilton/heater_shaker/heater_shaker.py | 10 +-- pylabrobot/hamilton/only_fans/backend.py | 5 +- pylabrobot/hamilton/only_fans/hepa_fan.py | 4 +- pylabrobot/hamilton/tilt_module/backend.py | 3 +- .../hamilton/tilt_module/tilt_module.py | 4 +- pylabrobot/inheco/cpac.py | 16 +++-- pylabrobot/inheco/scila/scila.py | 8 +-- pylabrobot/inheco/scila/scila_backend.py | 8 +-- pylabrobot/inheco/thermoshake.py | 16 ++--- pylabrobot/io/validation.py | 4 +- pylabrobot/keyence/keyence_backend.py | 3 +- pylabrobot/liconic/backend.py | 9 +-- pylabrobot/liconic/liconic.py | 6 +- pylabrobot/mettler_toledo/mettler_toledo.py | 8 +-- .../imageXpress/pico/backend.py | 3 +- .../imageXpress/pico/pico.py | 4 +- .../molecular_devices/spectramax/backend.py | 3 +- .../spectramax/spectramax_384_plus.py | 8 +-- .../spectramax/spectramax_m5.py | 8 +-- .../opentrons/temperature_module/backend.py | 5 +- .../temperature_module/temperature_module.py | 4 +- pylabrobot/qinstruments/bioshake.py | 72 +++++++++---------- pylabrobot/thermo_fisher/cytomat/backend.py | 9 +-- pylabrobot/thermo_fisher/cytomat/cytomat.py | 20 +++--- .../thermo_fisher/cytomat/heraeus_backend.py | 9 +-- 62 files changed, 280 insertions(+), 253 deletions(-) diff --git a/pylabrobot/agilent/biotek/biotek.py b/pylabrobot/agilent/biotek/biotek.py index 9c512f243e1..c64a371cada 100644 --- a/pylabrobot/agilent/biotek/biotek.py +++ b/pylabrobot/agilent/biotek/biotek.py @@ -15,6 +15,7 @@ LuminescenceBackend, LuminescenceResult, ) +from pylabrobot.device import Driver from pylabrobot.io.ftdi import FTDI from pylabrobot.resources import Plate, Well from pylabrobot.capabilities.capability import BackendParams @@ -23,7 +24,9 @@ logger = logging.getLogger(__name__) -class BioTekBackend(AbsorbanceBackend, LuminescenceBackend, FluorescenceBackend, metaclass=ABCMeta): +class BioTekBackend( + AbsorbanceBackend, LuminescenceBackend, FluorescenceBackend, Driver, metaclass=ABCMeta +): """Backend for Agilent BioTek plate readers.""" def __init__( diff --git a/pylabrobot/agilent/biotek/cytation.py b/pylabrobot/agilent/biotek/cytation.py index 2b5d1f3f687..8c55b85da5c 100644 --- a/pylabrobot/agilent/biotek/cytation.py +++ b/pylabrobot/agilent/biotek/cytation.py @@ -878,8 +878,8 @@ def __init__( size_z=size_z, model="Agilent BioTek Cytation 5", ) - Device.__init__(self, backend=backend) - self._backend: CytationBackend = backend + Device.__init__(self, driver=backend) + self._driver: CytationBackend = backend self.absorbance = AbsorbanceCapability(backend=backend) self.luminescence = LuminescenceCapability(backend=backend) self.fluorescence = FluorescenceCapability(backend=backend) @@ -900,10 +900,10 @@ def serialize(self) -> dict: return {**Resource.serialize(self), **Device.serialize(self)} async def open(self, slow: bool = False) -> None: - await self._backend.open(slow=slow) + await self._driver.open(slow=slow) async def close(self, slow: bool = False) -> None: - await self._backend.close(slow=slow) + await self._driver.close(slow=slow) class Cytation1(Resource, Device): @@ -926,8 +926,8 @@ def __init__( size_z=size_z, model="Agilent BioTek Cytation 1", ) - Device.__init__(self, backend=backend) - self._backend: BioTekBackend = backend + Device.__init__(self, driver=backend) + self._driver: BioTekBackend = backend self.absorbance = AbsorbanceCapability(backend=backend) self.luminescence = LuminescenceCapability(backend=backend) self.fluorescence = FluorescenceCapability(backend=backend) @@ -947,10 +947,10 @@ def serialize(self) -> dict: return {**Resource.serialize(self), **Device.serialize(self)} async def open(self, slow: bool = False) -> None: - await self._backend.open(slow=slow) + await self._driver.open(slow=slow) async def close(self, slow: bool = False) -> None: - await self._backend.close(slow=slow) + await self._driver.close(slow=slow) # Deprecated aliases diff --git a/pylabrobot/agilent/biotek/synergy_h1.py b/pylabrobot/agilent/biotek/synergy_h1.py index 97e8a4b1d21..af719bb8a1f 100644 --- a/pylabrobot/agilent/biotek/synergy_h1.py +++ b/pylabrobot/agilent/biotek/synergy_h1.py @@ -123,8 +123,8 @@ def __init__( size_z=size_z, model="Agilent BioTek Synergy H1", ) - Device.__init__(self, backend=backend) - self._backend: SynergyH1Backend = backend + Device.__init__(self, driver=backend) + self._driver: SynergyH1Backend = backend self.absorbance = AbsorbanceCapability(backend=backend) self.luminescence = LuminescenceCapability(backend=backend) self.fluorescence = FluorescenceCapability(backend=backend) @@ -144,7 +144,7 @@ def serialize(self) -> dict: return {**Resource.serialize(self), **Device.serialize(self)} async def open(self, slow: bool = False) -> None: - await self._backend.open(slow=slow) + await self._driver.open(slow=slow) async def close(self, slow: bool = False) -> None: - await self._backend.close(slow=slow) + await self._driver.close(slow=slow) diff --git a/pylabrobot/agilent/vspin/vspin.py b/pylabrobot/agilent/vspin/vspin.py index b253f973246..91629957731 100644 --- a/pylabrobot/agilent/vspin/vspin.py +++ b/pylabrobot/agilent/vspin/vspin.py @@ -18,7 +18,7 @@ LoaderNoPlateError, NotAtBucketError, ) -from pylabrobot.device import Device, DeviceBackend +from pylabrobot.device import Device, Driver from pylabrobot.io.ftdi import FTDI from pylabrobot.resources import Coordinate, Resource, ResourceHolder from pylabrobot.capabilities.capability import BackendParams @@ -68,7 +68,7 @@ def _save_vspin_calibrations(device_id, remainder: int): ) -class VSpinBackend(_NewCentrifugeBackend): +class VSpinBackend(_NewCentrifugeBackend, Driver): """Backend for the Agilent VSpin Centrifuge.""" def __init__(self, device_id: Optional[str] = None): @@ -471,7 +471,7 @@ async def _reset_to_zero(): raise RuntimeError("Home position did not change after spin.") -class Access2Backend(DeviceBackend): +class Access2Backend(Driver): """Backend for the Agilent Access2 centrifuge loader.""" def __init__( @@ -606,8 +606,8 @@ def __init__( model="Agilent VSpin", category="centrifuge", ) - Device.__init__(self, backend=backend) - self._backend: VSpinBackend = backend + Device.__init__(self, driver=backend) + self._driver: VSpinBackend = backend bucket1 = ResourceHolder( name=f"{name}_bucket1", @@ -656,8 +656,8 @@ def __init__( category="loader", child_location=Coordinate.zero(), ) - Device.__init__(self, backend=backend) - self._backend: Access2Backend = backend + Device.__init__(self, driver=backend) + self._driver: Access2Backend = backend self._vspin = vspin def serialize(self) -> dict: @@ -678,7 +678,7 @@ async def load(self) -> None: if centrifuging.at_bucket.resource is not None: raise BucketHasPlateError("Bucket must be empty to load a plate.") - await self._backend.load() + await self._driver.load() centrifuging.at_bucket.assign_child_resource(self.resource, location=Coordinate.zero()) @@ -695,6 +695,6 @@ async def unload(self) -> None: if centrifuging.at_bucket.resource is None: raise BucketNoPlateError("Bucket must have a plate to unload.") - await self._backend.unload() + await self._driver.unload() self.assign_child_resource(centrifuging.at_bucket.resource) diff --git a/pylabrobot/azenta/a4s.py b/pylabrobot/azenta/a4s.py index b44de1cf75a..a94e1fc7a7f 100644 --- a/pylabrobot/azenta/a4s.py +++ b/pylabrobot/azenta/a4s.py @@ -17,13 +17,13 @@ TemperatureControlCapability, TemperatureControllerBackend, ) -from pylabrobot.device import Device, DeviceBackend +from pylabrobot.device import Device, Driver from pylabrobot.io.serial import Serial from pylabrobot.resources import Coordinate from pylabrobot.resources.carrier import PlateHolder -class A4SBackend(SealerBackend, TemperatureControllerBackend): +class A4SBackend(SealerBackend, TemperatureControllerBackend, Driver): """Backend for the Azenta a4S thermal sealer. https://web.azenta.com/hubfs/azenta-files/resources/tech-drawings/TD-automated-roll-heat-sealer.pdf @@ -47,13 +47,13 @@ def __init__(self, port: str, timeout: int = 20) -> None: ) async def setup(self): - await DeviceBackend.setup(self) + await Driver.setup(self) await self.io.setup() await self.system_reset() async def stop(self): await self.set_heater(on=False) - await DeviceBackend.stop(self) + await Driver.stop(self) await self.io.stop() # -- serial protocol -- @@ -254,7 +254,7 @@ class A4S(PlateHolder, Device): def __init__( self, name: str, - backend: A4SBackend, + driver: A4SBackend, size_x: float = 222, size_y: float = 500, size_z: float = 276, @@ -275,10 +275,10 @@ def __init__( category=category, model=model, ) - Device.__init__(self, backend=backend) - self._backend: A4SBackend = backend - self.sealer = SealingCapability(backend=backend) - self.tc = TemperatureControlCapability(backend=backend) + Device.__init__(self, driver=driver) + self._driver: A4SBackend = driver + self.sealer = SealingCapability(backend=driver) + self.tc = TemperatureControlCapability(backend=driver) self._capabilities = [self.tc, self.sealer] def serialize(self) -> dict: diff --git a/pylabrobot/azenta/xpeel.py b/pylabrobot/azenta/xpeel.py index d9b5af60ec9..c3dfe8d636c 100644 --- a/pylabrobot/azenta/xpeel.py +++ b/pylabrobot/azenta/xpeel.py @@ -12,13 +12,13 @@ _SERIAL_IMPORT_ERROR = e from pylabrobot.capabilities.peeling import PeelerBackend, PeelingCapability -from pylabrobot.device import Device +from pylabrobot.device import Device, Driver from pylabrobot.io.serial import Serial from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin -class XPeelBackend(PeelerBackend): +class XPeelBackend(PeelerBackend, Driver): """Backend for the Azenta XPeel automated plate seal remover (RS-232).""" BAUDRATE = 9600 @@ -271,7 +271,7 @@ class XPeel(Device): def __init__(self, name: str, port: str, timeout: Optional[float] = None): backend = XPeelBackend(port=port, timeout=timeout) - super().__init__(backend=backend) - self._backend: XPeelBackend = backend + super().__init__(driver=backend) + self._driver: XPeelBackend = backend self.peeler = PeelingCapability(backend=backend) self._capabilities = [self.peeler] diff --git a/pylabrobot/bmg_labtech/clariostar.py b/pylabrobot/bmg_labtech/clariostar.py index 10e89e0c690..9d0dc6e3630 100644 --- a/pylabrobot/bmg_labtech/clariostar.py +++ b/pylabrobot/bmg_labtech/clariostar.py @@ -22,7 +22,7 @@ LuminescenceCapability, LuminescenceResult, ) -from pylabrobot.device import Device +from pylabrobot.device import Device, Driver from pylabrobot.io.ftdi import FTDI from pylabrobot.resources import Coordinate, PlateHolder, Resource from pylabrobot.resources.plate import Plate @@ -44,7 +44,7 @@ # --------------------------------------------------------------------------- -class CLARIOstarBackend(AbsorbanceBackend, LuminescenceBackend, FluorescenceBackend): +class CLARIOstarBackend(AbsorbanceBackend, LuminescenceBackend, FluorescenceBackend, Driver): """Backend for the BMG Labtech CLARIOstar plate reader. Communicates over FTDI USB (VID 0x0403, PID 0xBB68) at 125000 baud. @@ -405,8 +405,8 @@ def __init__( size_z=size_z, model="BMG CLARIOstar", ) - Device.__init__(self, backend=backend) - self._backend: CLARIOstarBackend = backend + Device.__init__(self, driver=backend) + self._driver: CLARIOstarBackend = backend self.absorbance = AbsorbanceCapability(backend=backend) self.luminescence = LuminescenceCapability(backend=backend) self.fluorescence = FluorescenceCapability(backend=backend) @@ -427,8 +427,8 @@ def serialize(self) -> dict: async def open(self) -> None: """Open the plate tray.""" - await self._backend.open() + await self._driver.open() async def close(self) -> None: """Close the plate tray.""" - await self._backend.close() + await self._driver.close() diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py index e9a3e1794ba..2b3f93dccd7 100644 --- a/pylabrobot/byonoy/absorbance_96.py +++ b/pylabrobot/byonoy/absorbance_96.py @@ -281,8 +281,8 @@ class ByonoyAbsorbance96(ByonoyAbsorbanceBaseUnit, Device): def __init__(self, name: str = "byonoy_absorbance_96"): backend = ByonoyAbsorbance96Backend() ByonoyAbsorbanceBaseUnit.__init__(self, name=name + "_base") - Device.__init__(self, backend=backend) - self._backend: ByonoyAbsorbance96Backend = backend + Device.__init__(self, driver=backend) + self._driver: ByonoyAbsorbance96Backend = backend self.absorbance = AbsorbanceCapability(backend=backend) self._capabilities = [self.absorbance] diff --git a/pylabrobot/byonoy/backend.py b/pylabrobot/byonoy/backend.py index 95955cfd833..015a9b4599b 100644 --- a/pylabrobot/byonoy/backend.py +++ b/pylabrobot/byonoy/backend.py @@ -5,7 +5,7 @@ from abc import ABCMeta from typing import Optional -from pylabrobot.device import DeviceBackend +from pylabrobot.device import Driver from pylabrobot.io.binary import Reader, Writer from pylabrobot.io.hid import HID @@ -15,7 +15,7 @@ class ByonoyDevice(enum.Enum): LUMINESCENCE_96 = enum.auto() -class ByonoyBase(DeviceBackend, metaclass=ABCMeta): +class ByonoyBase(Driver, metaclass=ABCMeta): """Shared HID communication logic for Byonoy plate readers.""" def __init__(self, pid: int, device_type: ByonoyDevice) -> None: diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index bd188a24db8..5ee139e7a77 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -240,8 +240,8 @@ def __init__( model="Byonoy L96 Reader Unit", preferred_pickup_location=preferred_pickup_location, ) - Device.__init__(self, backend=backend) - self._backend: ByonoyLuminescence96Backend = backend + Device.__init__(self, driver=backend) + self._driver: ByonoyLuminescence96Backend = backend self.luminescence = LuminescenceCapability(backend=backend) self._capabilities = [self.luminescence] diff --git a/pylabrobot/capabilities/__init__.py b/pylabrobot/capabilities/__init__.py index cbd53bcd61a..ddf84ebd973 100644 --- a/pylabrobot/capabilities/__init__.py +++ b/pylabrobot/capabilities/__init__.py @@ -1 +1 @@ -from .capability import Capability, need_capability_ready +from .capability import Capability, CapabilityBackend, need_capability_ready diff --git a/pylabrobot/capabilities/automated_retrieval/backend.py b/pylabrobot/capabilities/automated_retrieval/backend.py index 11048e98e69..5ad8179d946 100644 --- a/pylabrobot/capabilities/automated_retrieval/backend.py +++ b/pylabrobot/capabilities/automated_retrieval/backend.py @@ -1,10 +1,10 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend from pylabrobot.resources import Plate, PlateHolder -class AutomatedRetrievalBackend(DeviceBackend, metaclass=ABCMeta): +class AutomatedRetrievalBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for automated plate retrieval/storage devices.""" @abstractmethod diff --git a/pylabrobot/capabilities/barcode_scanning/backend.py b/pylabrobot/capabilities/barcode_scanning/backend.py index 49e89ee7d8c..1da12480996 100644 --- a/pylabrobot/capabilities/barcode_scanning/backend.py +++ b/pylabrobot/capabilities/barcode_scanning/backend.py @@ -1,6 +1,6 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend from pylabrobot.resources.barcode import Barcode @@ -8,7 +8,7 @@ class BarcodeScannerError(Exception): """Error raised by a barcode scanner backend.""" -class BarcodeScannerBackend(DeviceBackend, metaclass=ABCMeta): +class BarcodeScannerBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for barcode scanning devices.""" @abstractmethod diff --git a/pylabrobot/capabilities/capability.py b/pylabrobot/capabilities/capability.py index 7c2550ebbcc..bad76304ebe 100644 --- a/pylabrobot/capabilities/capability.py +++ b/pylabrobot/capabilities/capability.py @@ -5,9 +5,9 @@ from abc import ABC from typing import Any, Awaitable, Callable, TypeVar -from pylabrobot.device import DeviceBackend from pylabrobot.serializer import SerializableMixin + if sys.version_info < (3, 10): from typing_extensions import ParamSpec else: @@ -17,6 +17,12 @@ _R = TypeVar("_R", bound=Awaitable[Any]) +class CapabilityBackend(ABC): + """Base class for capability-specific backends.""" + + pass + + def need_capability_ready(func: Callable[_P, _R]) -> Callable[_P, _R]: """Decorator for methods that require the capability to be set up. @@ -62,12 +68,12 @@ class BackendParams(SerializableMixin, metaclass=_BackendParamsMeta): class Capability(ABC): """Base class for device capabilities. - Capabilities are owned by a Device and share its backend. They are not Resources + Capabilities are owned by a Device and share its driver. They are not Resources and do not appear in the resource tree. The parent Device is responsible for calling `_on_setup()` and `_on_stop()` during its own setup/stop lifecycle. """ - def __init__(self, backend: DeviceBackend): + def __init__(self, backend: CapabilityBackend): self.backend = backend self._setup_finished = False @@ -76,9 +82,9 @@ def setup_finished(self) -> bool: return self._setup_finished async def _on_setup(self): - """Called by the parent Device after backend.setup() completes.""" + """Called by the parent Device after driver.setup() completes.""" self._setup_finished = True async def _on_stop(self): - """Called by the parent Device before backend.stop().""" + """Called by the parent Device before driver.stop().""" self._setup_finished = False diff --git a/pylabrobot/capabilities/centrifuging/backend.py b/pylabrobot/capabilities/centrifuging/backend.py index e9f9159d518..335d8d95060 100644 --- a/pylabrobot/capabilities/centrifuging/backend.py +++ b/pylabrobot/capabilities/centrifuging/backend.py @@ -1,11 +1,11 @@ from abc import ABCMeta, abstractmethod from typing import Optional -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend from pylabrobot.serializer import SerializableMixin -class CentrifugeBackend(DeviceBackend, metaclass=ABCMeta): +class CentrifugeBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for centrifuge devices.""" @abstractmethod diff --git a/pylabrobot/capabilities/fan_control/backend.py b/pylabrobot/capabilities/fan_control/backend.py index 815336893b0..e8d4b9945e1 100644 --- a/pylabrobot/capabilities/fan_control/backend.py +++ b/pylabrobot/capabilities/fan_control/backend.py @@ -1,9 +1,9 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend -class FanBackend(DeviceBackend, metaclass=ABCMeta): +class FanBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for fan devices.""" @abstractmethod diff --git a/pylabrobot/capabilities/humidity_controlling/backend.py b/pylabrobot/capabilities/humidity_controlling/backend.py index 6b3c80ebcd1..ee60deeadf2 100644 --- a/pylabrobot/capabilities/humidity_controlling/backend.py +++ b/pylabrobot/capabilities/humidity_controlling/backend.py @@ -1,9 +1,9 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend -class HumidityControllerBackend(DeviceBackend, metaclass=ABCMeta): +class HumidityControllerBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for humidity controllers.""" @property diff --git a/pylabrobot/capabilities/microscopy/backend.py b/pylabrobot/capabilities/microscopy/backend.py index 8b44fe25079..8006a943b67 100644 --- a/pylabrobot/capabilities/microscopy/backend.py +++ b/pylabrobot/capabilities/microscopy/backend.py @@ -9,12 +9,12 @@ ImagingResult, Objective, ) -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend from pylabrobot.resources.plate import Plate from pylabrobot.serializer import SerializableMixin -class MicroscopyBackend(DeviceBackend, metaclass=ABCMeta): +class MicroscopyBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for microscopy devices.""" @abstractmethod diff --git a/pylabrobot/capabilities/microscopy/chatterbox.py b/pylabrobot/capabilities/microscopy/chatterbox.py index b161cf44170..640f263bb33 100644 --- a/pylabrobot/capabilities/microscopy/chatterbox.py +++ b/pylabrobot/capabilities/microscopy/chatterbox.py @@ -1,6 +1,7 @@ from typing import Optional from pylabrobot.capabilities.microscopy.backend import MicroscopyBackend +from pylabrobot.device import Driver from pylabrobot.capabilities.microscopy.standard import ( Exposure, FocalPosition, @@ -21,7 +22,7 @@ HAS_NUMPY = False -class MicroscopyChatterboxBackend(MicroscopyBackend): +class MicroscopyChatterboxBackend(MicroscopyBackend, Driver): """Mock microscopy backend for testing.""" async def setup(self) -> None: diff --git a/pylabrobot/capabilities/microscopy/microscopy_tests.py b/pylabrobot/capabilities/microscopy/microscopy_tests.py index 70abd42b7b4..d45c6b6e2f5 100644 --- a/pylabrobot/capabilities/microscopy/microscopy_tests.py +++ b/pylabrobot/capabilities/microscopy/microscopy_tests.py @@ -20,7 +20,7 @@ ImagingResult, Objective, ) -from pylabrobot.device import Device +from pylabrobot.device import Device, Driver from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType @@ -52,7 +52,7 @@ def _test_plate() -> Plate: ) -class RecordingMicroscopyBackend(MicroscopyBackend): +class RecordingMicroscopyBackend(MicroscopyBackend, Driver): """Backend that records all capture calls for assertion.""" def __init__(self): @@ -85,17 +85,17 @@ async def capture( class _TestMicroscope(Device): - def __init__(self, backend: MicroscopyBackend): - super().__init__(backend=backend) - self._backend = backend - self.microscopy = MicroscopyCapability(backend=backend) + def __init__(self, driver): + super().__init__(driver=driver) + self._driver = driver + self.microscopy = MicroscopyCapability(backend=driver) self._capabilities = [self.microscopy] class TestMicroscopyCapability(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingMicroscopyBackend() - self.device = _TestMicroscope(backend=self.backend) + self.device = _TestMicroscope(driver=self.backend) await self.device.setup() self.plate = _test_plate() @@ -171,7 +171,7 @@ async def test_capture_requires_setup(self): class TestChatterboxBackend(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_capture(self): backend = MicroscopyChatterboxBackend() - device = _TestMicroscope(backend=backend) + device = _TestMicroscope(driver=backend) await device.setup() plate = _test_plate() diff --git a/pylabrobot/capabilities/peeling/backend.py b/pylabrobot/capabilities/peeling/backend.py index e2663517b12..e3a64bc6de6 100644 --- a/pylabrobot/capabilities/peeling/backend.py +++ b/pylabrobot/capabilities/peeling/backend.py @@ -1,11 +1,11 @@ from abc import ABCMeta, abstractmethod from typing import Optional -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend from pylabrobot.serializer import SerializableMixin -class PeelerBackend(DeviceBackend, metaclass=ABCMeta): +class PeelerBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for peeling devices.""" @abstractmethod diff --git a/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py index c5c95d3a933..3a5ee920730 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py @@ -9,7 +9,7 @@ AbsorbanceChatterboxBackend, ) from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult -from pylabrobot.device import Device +from pylabrobot.device import Device, Driver from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType @@ -41,7 +41,7 @@ def _test_plate() -> Plate: ) -class RecordingAbsorbanceBackend(AbsorbanceBackend): +class RecordingAbsorbanceBackend(AbsorbanceBackend, Driver): """Backend that records all read_absorbance calls for assertion.""" def __init__(self): @@ -71,17 +71,17 @@ async def read_absorbance( class _TestDevice(Device): - def __init__(self, backend: AbsorbanceBackend): - super().__init__(backend=backend) - self._backend = backend - self.absorbance = AbsorbanceCapability(backend=backend) + def __init__(self, driver): + super().__init__(driver=driver) + self._driver = driver + self.absorbance = AbsorbanceCapability(backend=driver) self._capabilities = [self.absorbance] class TestAbsorbanceCapability(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingAbsorbanceBackend() - self.device = _TestDevice(backend=self.backend) + self.device = _TestDevice(driver=self.backend) await self.device.setup() self.plate = _test_plate() @@ -115,7 +115,7 @@ async def test_read_requires_setup(self): class TestAbsorbanceChatterbox(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_read(self): backend = AbsorbanceChatterboxBackend() - device = _TestDevice(backend=backend) + device = _TestDevice(driver=backend) await device.setup() plate = _test_plate() diff --git a/pylabrobot/capabilities/plate_reading/absorbance/backend.py b/pylabrobot/capabilities/plate_reading/absorbance/backend.py index 3b44509c1c8..816c54b2e3e 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/backend.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/backend.py @@ -4,13 +4,13 @@ from typing import List, Optional from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well from pylabrobot.serializer import SerializableMixin -class AbsorbanceBackend(DeviceBackend, metaclass=ABCMeta): +class AbsorbanceBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for absorbance plate reading.""" @abstractmethod diff --git a/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py b/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py index 12f52a460d8..89c91f6bbae 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py @@ -2,6 +2,7 @@ from typing import List, Optional from pylabrobot.capabilities.plate_reading.absorbance.backend import AbsorbanceBackend +from pylabrobot.device import Driver from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult from pylabrobot.capabilities.plate_reading.utils import mask_wells from pylabrobot.resources.plate import Plate @@ -9,7 +10,7 @@ from pylabrobot.serializer import SerializableMixin -class AbsorbanceChatterboxBackend(AbsorbanceBackend): +class AbsorbanceChatterboxBackend(AbsorbanceBackend, Driver): """Mock absorbance backend for testing.""" def __init__(self): diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/backend.py b/pylabrobot/capabilities/plate_reading/fluorescence/backend.py index 9849538da31..6a0111d7e2b 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/backend.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/backend.py @@ -4,13 +4,13 @@ from typing import List, Optional from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well from pylabrobot.serializer import SerializableMixin -class FluorescenceBackend(DeviceBackend, metaclass=ABCMeta): +class FluorescenceBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for fluorescence plate reading.""" @abstractmethod diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py b/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py index 3a22324cecc..5fd72962610 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py @@ -2,6 +2,7 @@ from typing import List, Optional from pylabrobot.capabilities.plate_reading.fluorescence.backend import FluorescenceBackend +from pylabrobot.device import Driver from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult from pylabrobot.capabilities.plate_reading.utils import mask_wells from pylabrobot.resources.plate import Plate @@ -9,7 +10,7 @@ from pylabrobot.serializer import SerializableMixin -class FluorescenceChatterboxBackend(FluorescenceBackend): +class FluorescenceChatterboxBackend(FluorescenceBackend, Driver): """Mock fluorescence backend for testing.""" def __init__(self): diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py index d75fa776684..df15e35e455 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py @@ -9,7 +9,7 @@ ) from pylabrobot.capabilities.plate_reading.fluorescence.fluorescence import FluorescenceCapability from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult -from pylabrobot.device import Device +from pylabrobot.device import Device, Driver from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType @@ -41,7 +41,7 @@ def _test_plate() -> Plate: ) -class RecordingFluorescenceBackend(FluorescenceBackend): +class RecordingFluorescenceBackend(FluorescenceBackend, Driver): """Backend that records all calls for assertion.""" def __init__(self): @@ -80,16 +80,16 @@ async def read_fluorescence( class _TestDevice(Device): - def __init__(self, backend: FluorescenceBackend): - super().__init__(backend=backend) - self.fluorescence = FluorescenceCapability(backend=backend) + def __init__(self, driver): + super().__init__(driver=driver) + self.fluorescence = FluorescenceCapability(backend=driver) self._capabilities = [self.fluorescence] class TestFluorescenceCapability(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingFluorescenceBackend() - self.device = _TestDevice(backend=self.backend) + self.device = _TestDevice(driver=self.backend) await self.device.setup() self.plate = _test_plate() @@ -141,7 +141,7 @@ async def test_read_requires_setup(self): class TestFluorescenceChatterbox(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_read(self): backend = FluorescenceChatterboxBackend() - device = _TestDevice(backend=backend) + device = _TestDevice(driver=backend) await device.setup() plate = _test_plate() diff --git a/pylabrobot/capabilities/plate_reading/luminescence/backend.py b/pylabrobot/capabilities/plate_reading/luminescence/backend.py index 3bb4888c261..b5820b96d97 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/backend.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/backend.py @@ -4,13 +4,13 @@ from typing import List, Optional from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well from pylabrobot.serializer import SerializableMixin -class LuminescenceBackend(DeviceBackend, metaclass=ABCMeta): +class LuminescenceBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for luminescence plate reading.""" @abstractmethod diff --git a/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py b/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py index 69cf1981b09..f49a066b982 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py @@ -2,6 +2,7 @@ from typing import List, Optional from pylabrobot.capabilities.plate_reading.luminescence.backend import LuminescenceBackend +from pylabrobot.device import Driver from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult from pylabrobot.capabilities.plate_reading.utils import mask_wells from pylabrobot.resources.plate import Plate @@ -9,7 +10,7 @@ from pylabrobot.serializer import SerializableMixin -class LuminescenceChatterboxBackend(LuminescenceBackend): +class LuminescenceChatterboxBackend(LuminescenceBackend, Driver): """Mock luminescence backend for testing.""" def __init__(self): diff --git a/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py index 062569b3a2a..b8d98caddab 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py @@ -9,7 +9,7 @@ ) from pylabrobot.capabilities.plate_reading.luminescence.luminescence import LuminescenceCapability from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult -from pylabrobot.device import Device +from pylabrobot.device import Device, Driver from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType @@ -41,7 +41,7 @@ def _test_plate() -> Plate: ) -class RecordingLuminescenceBackend(LuminescenceBackend): +class RecordingLuminescenceBackend(LuminescenceBackend, Driver): """Backend that records all calls for assertion.""" def __init__(self): @@ -68,16 +68,16 @@ async def read_luminescence( class _TestDevice(Device): - def __init__(self, backend: LuminescenceBackend): - super().__init__(backend=backend) - self.luminescence = LuminescenceCapability(backend=backend) + def __init__(self, driver): + super().__init__(driver=driver) + self.luminescence = LuminescenceCapability(backend=driver) self._capabilities = [self.luminescence] class TestLuminescenceCapability(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingLuminescenceBackend() - self.device = _TestDevice(backend=self.backend) + self.device = _TestDevice(driver=self.backend) await self.device.setup() self.plate = _test_plate() @@ -109,7 +109,7 @@ async def test_read_requires_setup(self): class TestLuminescenceChatterbox(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_read(self): backend = LuminescenceChatterboxBackend() - device = _TestDevice(backend=backend) + device = _TestDevice(driver=backend) await device.setup() plate = _test_plate() diff --git a/pylabrobot/capabilities/sealing/backend.py b/pylabrobot/capabilities/sealing/backend.py index 4152f97e8f2..9f2daa91fad 100644 --- a/pylabrobot/capabilities/sealing/backend.py +++ b/pylabrobot/capabilities/sealing/backend.py @@ -1,9 +1,9 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend -class SealerBackend(DeviceBackend, metaclass=ABCMeta): +class SealerBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for sealing devices.""" @abstractmethod diff --git a/pylabrobot/capabilities/shaking/backend.py b/pylabrobot/capabilities/shaking/backend.py index e23bcad26f3..f5e3453c82e 100644 --- a/pylabrobot/capabilities/shaking/backend.py +++ b/pylabrobot/capabilities/shaking/backend.py @@ -1,9 +1,9 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend -class ShakerBackend(DeviceBackend, metaclass=ABCMeta): +class ShakerBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for shaking devices.""" @abstractmethod diff --git a/pylabrobot/capabilities/temperature_controlling/backend.py b/pylabrobot/capabilities/temperature_controlling/backend.py index 77fd4c7f693..2470fd0fc56 100644 --- a/pylabrobot/capabilities/temperature_controlling/backend.py +++ b/pylabrobot/capabilities/temperature_controlling/backend.py @@ -1,9 +1,9 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend -class TemperatureControllerBackend(DeviceBackend, metaclass=ABCMeta): +class TemperatureControllerBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for temperature controllers.""" @property diff --git a/pylabrobot/capabilities/tilting/backend.py b/pylabrobot/capabilities/tilting/backend.py index 57eed4824e5..dfc22641baa 100644 --- a/pylabrobot/capabilities/tilting/backend.py +++ b/pylabrobot/capabilities/tilting/backend.py @@ -1,13 +1,13 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend class TiltModuleError(Exception): """Error raised by a tilt module backend.""" -class TilterBackend(DeviceBackend, metaclass=ABCMeta): +class TilterBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for tilting devices.""" @abstractmethod diff --git a/pylabrobot/capabilities/weighing/backend.py b/pylabrobot/capabilities/weighing/backend.py index 3b541e2e22d..f9418a284bf 100644 --- a/pylabrobot/capabilities/weighing/backend.py +++ b/pylabrobot/capabilities/weighing/backend.py @@ -1,9 +1,9 @@ from abc import ABCMeta, abstractmethod -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend -class ScaleBackend(DeviceBackend, metaclass=ABCMeta): +class ScaleBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for scales.""" @abstractmethod diff --git a/pylabrobot/device.py b/pylabrobot/device.py index aa696939d74..fe95603e013 100644 --- a/pylabrobot/device.py +++ b/pylabrobot/device.py @@ -22,10 +22,10 @@ _R = TypeVar("_R", bound=Awaitable[Any]) -class DeviceBackend(SerializableMixin, ABC): - """Abstract class for device backends.""" +class Driver(SerializableMixin, ABC): + """Abstract class for hardware drivers.""" - _instances: weakref.WeakSet["DeviceBackend"] = weakref.WeakSet() + _instances: weakref.WeakSet["Driver"] = weakref.WeakSet() def __init__(self): self._instances.add(self) @@ -57,6 +57,9 @@ def get_all_instances(cls): return cls._instances +DeviceBackend = Driver # backward compat alias + + def need_setup_finished(func: Callable[_P, _R]) -> Callable[_P, _R]: """Decorator for methods that require the device to be set up. @@ -81,8 +84,8 @@ async def wrapper(*args, **kwargs): class Device(SerializableMixin, ABC): """Abstract base class for device frontends.""" - def __init__(self, backend: DeviceBackend): - self._backend = backend + def __init__(self, driver: Driver): + self._driver = driver self._setup_finished = False self._capabilities: List[Capability] = [] @@ -91,18 +94,18 @@ def setup_finished(self) -> bool: return self._setup_finished def serialize(self) -> dict: - return {"backend": self._backend.serialize()} + return {"driver": self._driver.serialize()} @classmethod def deserialize(cls, data: dict): data_copy = data.copy() - backend_data = data_copy.pop("backend") - backend = DeviceBackend.deserialize(backend_data) - data_copy["backend"] = backend + driver_data = data_copy.pop("driver", None) or data_copy.pop("backend", None) + driver = Driver.deserialize(driver_data) + data_copy["driver"] = driver return cls(**data_copy) - async def setup(self, **backend_kwargs): - await self._backend.setup(**backend_kwargs) + async def setup(self): + await self._driver.setup() for cap in self._capabilities: await cap._on_setup() self._setup_finished = True @@ -111,7 +114,7 @@ async def setup(self, **backend_kwargs): async def stop(self): for cap in reversed(self._capabilities): await cap._on_stop() - await self._backend.stop() + await self._driver.stop() self._setup_finished = False async def __aenter__(self): diff --git a/pylabrobot/hamilton/heater_shaker/backend.py b/pylabrobot/hamilton/heater_shaker/backend.py index 7008c7fc5e3..9eb3afe083d 100644 --- a/pylabrobot/hamilton/heater_shaker/backend.py +++ b/pylabrobot/hamilton/heater_shaker/backend.py @@ -5,7 +5,7 @@ from pylabrobot.capabilities.shaking import ShakerBackend from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend -from pylabrobot.device import DeviceBackend +from pylabrobot.device import Driver from .box import HamiltonHeaterShakerInterface @@ -15,7 +15,7 @@ class PlateLockPosition(Enum): UNLOCKED = 0 -class HamiltonHeaterShakerBackend(TemperatureControllerBackend, ShakerBackend): +class HamiltonHeaterShakerBackend(TemperatureControllerBackend, ShakerBackend, Driver): """Backend for Hamilton Heater Shaker devices.""" def __init__(self, index: int, interface: HamiltonHeaterShakerInterface) -> None: @@ -28,17 +28,17 @@ def supports_active_cooling(self) -> bool: return False async def setup(self): - await DeviceBackend.setup(self) + await Driver.setup(self) await self._initialize_lock() await self._initialize_shaker_drive() async def stop(self): - await DeviceBackend.stop(self) + await Driver.stop(self) def serialize(self) -> dict: warnings.warn("The interface is not serialized.") return { - **DeviceBackend.serialize(self), + **Driver.serialize(self), "index": self.index, "interface": None, } diff --git a/pylabrobot/hamilton/heater_shaker/heater_shaker.py b/pylabrobot/hamilton/heater_shaker/heater_shaker.py index fda19b8f300..3de7848b4a9 100644 --- a/pylabrobot/hamilton/heater_shaker/heater_shaker.py +++ b/pylabrobot/hamilton/heater_shaker/heater_shaker.py @@ -15,7 +15,7 @@ class HamiltonHeaterShaker(PlateHolder, Device): def __init__( self, name: str, - backend: HamiltonHeaterShakerBackend, + driver: HamiltonHeaterShakerBackend, size_x: float = 146.2, size_y: float = 103.6, size_z: float = 74.11, @@ -36,10 +36,10 @@ def __init__( category=category, model=model, ) - Device.__init__(self, backend=backend) - self._backend: HamiltonHeaterShakerBackend = backend - self.tc = TemperatureControlCapability(backend=backend) - self.shaker = ShakingCapability(backend=backend) + Device.__init__(self, driver=driver) + self._driver: HamiltonHeaterShakerBackend = driver + self.tc = TemperatureControlCapability(backend=driver) + self.shaker = ShakingCapability(backend=driver) self._capabilities = [self.tc, self.shaker] def serialize(self) -> dict: diff --git a/pylabrobot/hamilton/only_fans/backend.py b/pylabrobot/hamilton/only_fans/backend.py index 54012ce34de..e83fbbd3eb5 100644 --- a/pylabrobot/hamilton/only_fans/backend.py +++ b/pylabrobot/hamilton/only_fans/backend.py @@ -2,10 +2,11 @@ from typing import Optional from pylabrobot.capabilities.fan_control import FanBackend +from pylabrobot.device import Driver from pylabrobot.io.ftdi import FTDI -class HamiltonHepaFanBackend(FanBackend): +class HamiltonHepaFanBackend(FanBackend, Driver): """Backend for the Hamilton HEPA fan attachment.""" def __init__(self, device_id: Optional[str] = None): @@ -153,7 +154,7 @@ async def _send(self, command: bytes): await self.io.read(64) -class HamiltonHepaFanChatterboxBackend(FanBackend): +class HamiltonHepaFanChatterboxBackend(FanBackend, Driver): """Chatterbox backend for device-free testing.""" async def setup(self) -> None: diff --git a/pylabrobot/hamilton/only_fans/hepa_fan.py b/pylabrobot/hamilton/only_fans/hepa_fan.py index 42bd521a387..e09c6e7e3c0 100644 --- a/pylabrobot/hamilton/only_fans/hepa_fan.py +++ b/pylabrobot/hamilton/only_fans/hepa_fan.py @@ -11,7 +11,7 @@ class HamiltonHepaFan(Device): def __init__(self, name: str, device_id: Optional[str] = None): backend = HamiltonHepaFanBackend(device_id=device_id) - super().__init__(backend=backend) - self._backend: HamiltonHepaFanBackend = backend + super().__init__(driver=backend) + self._driver: HamiltonHepaFanBackend = backend self.fan = FanControlCapability(backend=backend) self._capabilities = [self.fan] diff --git a/pylabrobot/hamilton/tilt_module/backend.py b/pylabrobot/hamilton/tilt_module/backend.py index c598068d0f7..cd8c867038e 100644 --- a/pylabrobot/hamilton/tilt_module/backend.py +++ b/pylabrobot/hamilton/tilt_module/backend.py @@ -10,10 +10,11 @@ _SERIAL_IMPORT_ERROR = e from pylabrobot.capabilities.tilting.backend import TilterBackend, TiltModuleError +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial -class HamiltonTiltModuleBackend(TilterBackend): +class HamiltonTiltModuleBackend(TilterBackend, Driver): """Backend for the Hamilton tilt module.""" def __init__( diff --git a/pylabrobot/hamilton/tilt_module/tilt_module.py b/pylabrobot/hamilton/tilt_module/tilt_module.py index d3a19f94b70..606d30d4776 100644 --- a/pylabrobot/hamilton/tilt_module/tilt_module.py +++ b/pylabrobot/hamilton/tilt_module/tilt_module.py @@ -37,8 +37,8 @@ def __init__( category="tilter", model="HamiltonTiltModule", ) - Device.__init__(self, backend=backend) - self._backend: HamiltonTiltModuleBackend = backend + Device.__init__(self, driver=backend) + self._driver: HamiltonTiltModuleBackend = backend self.pedestal_size_z = pedestal_size_z self._hinge_coordinate = Coordinate(6.18, 0, 72.85) diff --git a/pylabrobot/inheco/cpac.py b/pylabrobot/inheco/cpac.py index 18f138b90df..37c524d2909 100644 --- a/pylabrobot/inheco/cpac.py +++ b/pylabrobot/inheco/cpac.py @@ -6,13 +6,15 @@ TemperatureControlCapability, TemperatureControllerBackend, ) -from pylabrobot.device import Device +from pylabrobot.device import Device, Driver from pylabrobot.resources import Coordinate, ResourceHolder from .control_box import InhecoTECControlBox -class InhecoTemperatureControllerBackend(TemperatureControllerBackend, metaclass=abc.ABCMeta): +class InhecoTemperatureControllerBackend( + TemperatureControllerBackend, Driver, metaclass=abc.ABCMeta +): """Universal backend for Inheco Temperature Controller devices such as ThermoShake and CPAC""" @property @@ -99,7 +101,7 @@ def __init__( size_x: float, size_y: float, size_z: float, - backend: InhecoCPACBackend, + driver: InhecoCPACBackend, child_location: Coordinate, category: str = "temperature_controller", model: Optional[str] = None, @@ -114,9 +116,9 @@ def __init__( category=category, model=model, ) - Device.__init__(self, backend=backend) - self._backend: InhecoCPACBackend = backend - self.tc = TemperatureControlCapability(backend=backend) + Device.__init__(self, driver=driver) + self._driver: InhecoCPACBackend = driver + self.tc = TemperatureControlCapability(backend=driver) self._capabilities = [self.tc] def serialize(self) -> dict: @@ -135,7 +137,7 @@ def inheco_cpac_ultraflat(name: str, control_box: InhecoTECControlBox, index: in return InhecoCPAC( name=name, - backend=InhecoCPACBackend(control_box=control_box, index=index), + driver=InhecoCPACBackend(control_box=control_box, index=index), size_x=113, # from spec size_y=89, # from spec size_z=129, # from spec diff --git a/pylabrobot/inheco/scila/scila.py b/pylabrobot/inheco/scila/scila.py index f736632f1e0..00c886781fe 100644 --- a/pylabrobot/inheco/scila/scila.py +++ b/pylabrobot/inheco/scila/scila.py @@ -7,11 +7,11 @@ class SCILA(Device): """Inheco SCILA incubator with 4 drawers and temperature control.""" - def __init__(self, name: str, backend: SCILABackend): + def __init__(self, name: str, driver: SCILABackend): raise NotImplementedError("SCILA is missing resource definition.") - Device.__init__(self, backend=backend) - self._backend: SCILABackend = backend - self.tc = TemperatureControlCapability(backend=backend) + Device.__init__(self, driver=driver) + self._driver: SCILABackend = driver + self.tc = TemperatureControlCapability(backend=driver) self._capabilities = [self.tc] def serialize(self) -> dict: diff --git a/pylabrobot/inheco/scila/scila_backend.py b/pylabrobot/inheco/scila/scila_backend.py index 0a296032a59..dd05d24012d 100644 --- a/pylabrobot/inheco/scila/scila_backend.py +++ b/pylabrobot/inheco/scila/scila_backend.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Literal, Optional from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend -from pylabrobot.device import DeviceBackend +from pylabrobot.device import Driver from .inheco_sila_interface import InhecoSiLAInterface @@ -36,7 +36,7 @@ def _get_params(root: ET.Element, names: list[str]) -> dict[str, object]: DrawerStatus = Literal["Opened", "Closed"] -class SCILABackend(TemperatureControllerBackend): +class SCILABackend(TemperatureControllerBackend, Driver): """Backend for Inheco SciLa incubators. Communicates over HTTP/SOAP via the SiLA interface. @@ -46,12 +46,12 @@ def __init__(self, scila_ip: str, client_ip: Optional[str] = None) -> None: self._sila_interface = InhecoSiLAInterface(client_ip=client_ip, machine_ip=scila_ip) async def setup(self) -> None: - await DeviceBackend.setup(self) + await Driver.setup(self) await self._sila_interface.setup() await self._reset_and_initialize() async def stop(self) -> None: - await DeviceBackend.stop(self) + await Driver.stop(self) await self._sila_interface.close() async def _reset_and_initialize(self) -> None: diff --git a/pylabrobot/inheco/thermoshake.py b/pylabrobot/inheco/thermoshake.py index 2cf6ea90426..c38e2a48b43 100644 --- a/pylabrobot/inheco/thermoshake.py +++ b/pylabrobot/inheco/thermoshake.py @@ -82,7 +82,7 @@ def __init__( size_x: float, size_y: float, size_z: float, - backend: InhecoThermoshakeBackend, + driver: InhecoThermoshakeBackend, child_location: Coordinate, category: str = "heating_shaking", model: Optional[str] = None, @@ -97,10 +97,10 @@ def __init__( category=category, model=model, ) - Device.__init__(self, backend=backend) - self._backend: InhecoThermoshakeBackend = backend - self.tc = TemperatureControlCapability(backend=backend) - self.shaker = ShakingCapability(backend=backend) + Device.__init__(self, driver=driver) + self._driver: InhecoThermoshakeBackend = driver + self.tc = TemperatureControlCapability(backend=driver) + self.shaker = ShakingCapability(backend=driver) self._capabilities = [self.tc, self.shaker] def serialize(self) -> dict: @@ -124,7 +124,7 @@ def inheco_thermoshake_ac( return InhecoThermoShake( name=name, - backend=InhecoThermoshakeBackend(control_box=control_box, index=index), + driver=InhecoThermoshakeBackend(control_box=control_box, index=index), size_x=147, # from spec size_y=104, # from spec size_z=115.9, # from spec @@ -143,7 +143,7 @@ def inheco_thermoshake( return InhecoThermoShake( name=name, - backend=InhecoThermoshakeBackend(control_box=control_box, index=index), + driver=InhecoThermoshakeBackend(control_box=control_box, index=index), size_x=147, # from spec size_y=104, # from spec size_z=118, # from spec @@ -165,7 +165,7 @@ def inheco_thermoshake_rm( return InhecoThermoShake( name=name, - backend=InhecoThermoshakeBackend(control_box=control_box, index=index), + driver=InhecoThermoshakeBackend(control_box=control_box, index=index), size_x=147, # from spec size_y=104, # from spec size_z=116, # from spec diff --git a/pylabrobot/io/validation.py b/pylabrobot/io/validation.py index 58a47e3484c..97fff0fccb4 100644 --- a/pylabrobot/io/validation.py +++ b/pylabrobot/io/validation.py @@ -1,6 +1,6 @@ from typing import Optional -from pylabrobot.device import DeviceBackend +from pylabrobot.device import Driver from pylabrobot.io.capture import CaptureReader, capturer from pylabrobot.io.ftdi import FTDI, FTDIValidator from pylabrobot.io.hid import HID, HIDValidator @@ -40,7 +40,7 @@ def _replace_io(obj): return False return True - for machine_backend in DeviceBackend.get_all_instances(): + for machine_backend in Driver.get_all_instances(): if not ( (hasattr(machine_backend, "io") and _replace_io(machine_backend)) or (hasattr(machine_backend, "interface") and _replace_io(machine_backend.interface)) diff --git a/pylabrobot/keyence/keyence_backend.py b/pylabrobot/keyence/keyence_backend.py index 25e8aadb7a6..a1c331cece1 100644 --- a/pylabrobot/keyence/keyence_backend.py +++ b/pylabrobot/keyence/keyence_backend.py @@ -14,13 +14,14 @@ BarcodeScannerBackend, BarcodeScannerError, ) +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial from pylabrobot.resources.barcode import Barcode logger = logging.getLogger(__name__) -class KeyenceBarcodeScannerBackend(BarcodeScannerBackend): +class KeyenceBarcodeScannerBackend(BarcodeScannerBackend, Driver): default_baudrate = 9600 serial_messaging_encoding = "ascii" init_timeout = 1.0 # seconds diff --git a/pylabrobot/liconic/backend.py b/pylabrobot/liconic/backend.py index ea5bc3baa6c..a0242d7f51b 100644 --- a/pylabrobot/liconic/backend.py +++ b/pylabrobot/liconic/backend.py @@ -18,7 +18,7 @@ from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend from pylabrobot.capabilities.shaking.backend import ShakerBackend from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend -from pylabrobot.device import DeviceBackend +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial from pylabrobot.resources import Plate, PlateHolder from pylabrobot.resources.barcode import Barcode @@ -51,6 +51,7 @@ class LiconicBackend( TemperatureControllerBackend, HumidityControllerBackend, ShakerBackend, + Driver, ): """Backend for Liconic incubators.""" @@ -97,7 +98,7 @@ def __init__( self.n2_installed: Optional[bool] = None async def setup(self): - await DeviceBackend.setup(self) + await Driver.setup(self) try: await self.io.setup() except serial.SerialException as e: @@ -137,7 +138,7 @@ async def setup(self): async def stop(self): await self.io.stop() - await DeviceBackend.stop(self) + await Driver.stop(self) async def set_racks(self, racks: List[PlateCarrier]): self._racks = racks @@ -493,7 +494,7 @@ async def check_second_transfer_sensor(self) -> bool: def serialize(self) -> dict: return { - **DeviceBackend.serialize(self), + **Driver.serialize(self), "port": self.io.port, "model": self.model.value, } diff --git a/pylabrobot/liconic/liconic.py b/pylabrobot/liconic/liconic.py index f8b0daa7553..7ebdd0b4bae 100644 --- a/pylabrobot/liconic/liconic.py +++ b/pylabrobot/liconic/liconic.py @@ -55,8 +55,8 @@ def __init__( category=category, model=model, ) - Device.__init__(self, backend=backend) - self._backend: LiconicBackend = backend + Device.__init__(self, driver=backend) + self._driver: LiconicBackend = backend self.loading_tray = PlateHolder( name=f"{name}_tray", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0 @@ -99,7 +99,7 @@ async def setup(self, **backend_kwargs): if self.barcode_scanner is not None: await self.barcode_scanner.backend.setup() await super().setup() - await self._backend.set_racks(self._racks) + await self._driver.set_racks(self._racks) async def stop(self): await super().stop() diff --git a/pylabrobot/mettler_toledo/mettler_toledo.py b/pylabrobot/mettler_toledo/mettler_toledo.py index 74e702cddf1..6094d105287 100644 --- a/pylabrobot/mettler_toledo/mettler_toledo.py +++ b/pylabrobot/mettler_toledo/mettler_toledo.py @@ -6,7 +6,7 @@ from typing import List, Literal, Optional, Union from pylabrobot.capabilities.weighing import ScaleBackend -from pylabrobot.device import DeviceBackend +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial logger = logging.getLogger("pylabrobot") @@ -143,7 +143,7 @@ def adjustment_needed(from_terminal: bool) -> "MettlerToledoError": MettlerToledoResponse = List[str] -class MettlerToledoWXS205SDUBackend(ScaleBackend): +class MettlerToledoWXS205SDUBackend(ScaleBackend, Driver): """Backend for the Mettler Toledo WXS205SDU scale. This scale is used by Hamilton in the liquid verification kit (LVK). @@ -173,7 +173,7 @@ def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6 ) async def setup(self) -> None: - await DeviceBackend.setup(self) + await Driver.setup(self) await self.io.setup() # set output unit to grams @@ -183,7 +183,7 @@ async def setup(self) -> None: self.serial_number = await self.request_serial_number() async def stop(self) -> None: - await DeviceBackend.stop(self) + await Driver.stop(self) await self.io.stop() def serialize(self) -> dict: diff --git a/pylabrobot/molecular_devices/imageXpress/pico/backend.py b/pylabrobot/molecular_devices/imageXpress/pico/backend.py index 9f411bad19a..b0970719292 100644 --- a/pylabrobot/molecular_devices/imageXpress/pico/backend.py +++ b/pylabrobot/molecular_devices/imageXpress/pico/backend.py @@ -18,6 +18,7 @@ MicroscopyBackend, Objective, ) +from pylabrobot.device import Driver from pylabrobot.io.sila.grpc import ( command_execution_uuid, decode_command_confirmation, @@ -313,7 +314,7 @@ def _buffer_to_ndarray(image_buffer: bytes, width: int, height: int): } -class PicoBackend(MicroscopyBackend): +class PicoBackend(MicroscopyBackend, Driver): """Backend for Molecular Devices ImageXpress Pico automated microscope. Communicates with the instrument via SiLA 2 over gRPC. diff --git a/pylabrobot/molecular_devices/imageXpress/pico/pico.py b/pylabrobot/molecular_devices/imageXpress/pico/pico.py index a88d8bf92cf..d58ee261539 100644 --- a/pylabrobot/molecular_devices/imageXpress/pico/pico.py +++ b/pylabrobot/molecular_devices/imageXpress/pico/pico.py @@ -54,8 +54,8 @@ def __init__( category=category, model=model, ) - Device.__init__(self, backend=backend) - self._backend: PicoBackend = backend + Device.__init__(self, driver=backend) + self._driver: PicoBackend = backend self.microscopy = MicroscopyCapability(backend=backend) self._capabilities = [self.microscopy] diff --git a/pylabrobot/molecular_devices/spectramax/backend.py b/pylabrobot/molecular_devices/spectramax/backend.py index 22721d85952..12a6db8f2ba 100644 --- a/pylabrobot/molecular_devices/spectramax/backend.py +++ b/pylabrobot/molecular_devices/spectramax/backend.py @@ -7,6 +7,7 @@ from typing import Dict, List, Literal, Optional, Tuple, Union from pylabrobot.capabilities.plate_reading.absorbance.backend import AbsorbanceBackend +from pylabrobot.device import Driver from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend from pylabrobot.io.serial import Serial @@ -250,7 +251,7 @@ class MolecularDevicesSettings: settling_time: int = 0 -class MolecularDevicesBackend(AbsorbanceBackend, TemperatureControllerBackend): +class MolecularDevicesBackend(AbsorbanceBackend, TemperatureControllerBackend, Driver): """Backend for Molecular Devices plate readers. Supports absorbance reading. Contains all serial protocol code, enums, dataclasses, and exceptions shared diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py b/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py index 0c8563a520b..8c3b9bd1b2f 100644 --- a/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py +++ b/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py @@ -52,8 +52,8 @@ def __init__( size_z=size_z, model="Molecular Devices SpectraMax 384 Plus", ) - Device.__init__(self, backend=backend) - self._backend: SpectraMax384PlusBackend = backend + Device.__init__(self, driver=backend) + self._driver: SpectraMax384PlusBackend = backend self.absorbance = AbsorbanceCapability(backend=backend) self.tc = TemperatureControlCapability(backend=backend) self._capabilities = [self.absorbance, self.tc] @@ -72,7 +72,7 @@ def serialize(self) -> dict: return {**Resource.serialize(self), **Device.serialize(self)} async def open(self) -> None: - await self._backend.open() + await self._driver.open() async def close(self) -> None: - await self._backend.close() + await self._driver.close() diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py index ef33fd174e1..e9dbb2f0ea8 100644 --- a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py +++ b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py @@ -371,8 +371,8 @@ def __init__( size_z=size_z, model="Molecular Devices SpectraMax M5", ) - Device.__init__(self, backend=backend) - self._backend: SpectraMaxM5Backend = backend + Device.__init__(self, driver=backend) + self._driver: SpectraMaxM5Backend = backend self.absorbance = AbsorbanceCapability(backend=backend) self.luminescence = LuminescenceCapability(backend=backend) self.fluorescence = FluorescenceCapability(backend=backend) @@ -393,7 +393,7 @@ def serialize(self) -> dict: return {**Resource.serialize(self), **Device.serialize(self)} async def open(self) -> None: - await self._backend.open() + await self._driver.open() async def close(self) -> None: - await self._backend.close() + await self._driver.close() diff --git a/pylabrobot/opentrons/temperature_module/backend.py b/pylabrobot/opentrons/temperature_module/backend.py index 73b3aaabe82..01633a605e4 100644 --- a/pylabrobot/opentrons/temperature_module/backend.py +++ b/pylabrobot/opentrons/temperature_module/backend.py @@ -1,6 +1,7 @@ from typing import Optional, cast from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial try: @@ -12,7 +13,7 @@ _OT_IMPORT_ERROR = e -class OpentronsTemperatureModuleBackend(TemperatureControllerBackend): +class OpentronsTemperatureModuleBackend(TemperatureControllerBackend, Driver): """Backend for the Opentrons Temperature Module v2 via the Opentrons HTTP API.""" @property @@ -59,7 +60,7 @@ async def get_current_temperature(self) -> float: raise RuntimeError(f"Module with id '{self.opentrons_id}' not found") -class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend): +class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend, Driver): """Backend for the Opentrons Temperature Module v2 via direct USB serial.""" @property diff --git a/pylabrobot/opentrons/temperature_module/temperature_module.py b/pylabrobot/opentrons/temperature_module/temperature_module.py index 924fc609795..3ec4e7283e3 100644 --- a/pylabrobot/opentrons/temperature_module/temperature_module.py +++ b/pylabrobot/opentrons/temperature_module/temperature_module.py @@ -67,8 +67,8 @@ def __init__( category="temperature_controller", model="temperatureModuleV2", ) - Device.__init__(self, backend=backend) - self._backend = backend + Device.__init__(self, driver=backend) + self._driver = backend self.tc = TemperatureControlCapability(backend=backend) self._capabilities = [self.tc] diff --git a/pylabrobot/qinstruments/bioshake.py b/pylabrobot/qinstruments/bioshake.py index 4a0b6dbb72e..e0412591508 100644 --- a/pylabrobot/qinstruments/bioshake.py +++ b/pylabrobot/qinstruments/bioshake.py @@ -6,7 +6,7 @@ TemperatureControlCapability, TemperatureControllerBackend, ) -from pylabrobot.device import Device, DeviceBackend +from pylabrobot.device import Device, Driver from pylabrobot.io.serial import Serial from pylabrobot.resources import Coordinate from pylabrobot.resources.carrier import PlateHolder @@ -20,7 +20,7 @@ _SERIAL_IMPORT_ERROR = e -class BioShakeBackend(TemperatureControllerBackend, ShakerBackend): +class BioShakeBackend(TemperatureControllerBackend, ShakerBackend, Driver): """Backend for all QInstruments BioShake models. All models share the same firmware. Commands that are not supported by a given @@ -78,7 +78,7 @@ async def _send_command(self, cmd: str, delay: float = 0.5, timeout: float = 2): raise RuntimeError(f"Unexpected error while sending '{cmd}': {type(e).__name__}: {e}") from e async def setup(self, skip_home: bool = False): - await DeviceBackend.setup(self) + await Driver.setup(self) await self.io.setup() if not skip_home: await self.reset() @@ -86,7 +86,7 @@ async def setup(self, skip_home: bool = False): await self.home() async def stop(self): - await DeviceBackend.stop(self) + await Driver.stop(self) await self.io.stop() async def reset(self): @@ -238,7 +238,7 @@ def __init__( size_x: float, size_y: float, size_z: float, - backend: BioShakeBackend, + driver: BioShakeBackend, child_location: Coordinate, pedestal_size_z: float, category: str = "bioshake", @@ -255,8 +255,8 @@ def __init__( category=category, model=model, ) - Device.__init__(self, backend=backend) - self._backend: BioShakeBackend = backend + Device.__init__(self, driver=driver) + self._driver: BioShakeBackend = driver def serialize(self) -> dict: return { @@ -276,14 +276,14 @@ def __init__(self, name: str, port: str): raise NotImplementedError("BioShake3000 is missing resource definition.") super().__init__( name=name, - backend=BioShakeBackend(port=port), + driver=BioShakeBackend(port=port), size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.shaker = ShakingCapability(backend=self._backend) + self.shaker = ShakingCapability(backend=self._driver) self._capabilities = [self.shaker] @@ -294,14 +294,14 @@ def __init__(self, name: str, port: str): raise NotImplementedError("BioShake3000Elm is missing resource definition.") super().__init__( name=name, - backend=BioShakeBackend(port=port), + driver=BioShakeBackend(port=port), size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.shaker = ShakingCapability(backend=self._backend) + self.shaker = ShakingCapability(backend=self._driver) self._capabilities = [self.shaker] @@ -312,14 +312,14 @@ def __init__(self, name: str, port: str): raise NotImplementedError("BioShake3000ElmDWP is missing resource definition.") super().__init__( name=name, - backend=BioShakeBackend(port=port), + driver=BioShakeBackend(port=port), size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.shaker = ShakingCapability(backend=self._backend) + self.shaker = ShakingCapability(backend=self._driver) self._capabilities = [self.shaker] @@ -330,14 +330,14 @@ def __init__(self, name: str, port: str): raise NotImplementedError("BioShakeD30Elm is missing resource definition.") super().__init__( name=name, - backend=BioShakeBackend(port=port), + driver=BioShakeBackend(port=port), size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.shaker = ShakingCapability(backend=self._backend) + self.shaker = ShakingCapability(backend=self._driver) self._capabilities = [self.shaker] @@ -348,14 +348,14 @@ def __init__(self, name: str, port: str): raise NotImplementedError("BioShake5000Elm is missing resource definition.") super().__init__( name=name, - backend=BioShakeBackend(port=port), + driver=BioShakeBackend(port=port), size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.shaker = ShakingCapability(backend=self._backend) + self.shaker = ShakingCapability(backend=self._driver) self._capabilities = [self.shaker] @@ -369,15 +369,15 @@ def __init__(self, name: str, port: str): raise NotImplementedError("BioShake3000T is missing resource definition.") super().__init__( name=name, - backend=BioShakeBackend(port=port), + driver=BioShakeBackend(port=port), size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._backend) - self.shaker = ShakingCapability(backend=self._backend) + self.tc = TemperatureControlCapability(backend=self._driver) + self.shaker = ShakingCapability(backend=self._driver) self._capabilities = [self.tc, self.shaker] @@ -388,15 +388,15 @@ def __init__(self, name: str, port: str): raise NotImplementedError("BioShake3000TElm is missing resource definition.") super().__init__( name=name, - backend=BioShakeBackend(port=port), + driver=BioShakeBackend(port=port), size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._backend) - self.shaker = ShakingCapability(backend=self._backend) + self.tc = TemperatureControlCapability(backend=self._driver) + self.shaker = ShakingCapability(backend=self._driver) self._capabilities = [self.tc, self.shaker] @@ -407,15 +407,15 @@ def __init__(self, name: str, port: str): raise NotImplementedError("BioShakeD30TElm is missing resource definition.") super().__init__( name=name, - backend=BioShakeBackend(port=port), + driver=BioShakeBackend(port=port), size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._backend) - self.shaker = ShakingCapability(backend=self._backend) + self.tc = TemperatureControlCapability(backend=self._driver) + self.shaker = ShakingCapability(backend=self._driver) self._capabilities = [self.tc, self.shaker] @@ -429,15 +429,15 @@ def __init__(self, name: str, port: str): raise NotImplementedError("BioShakeQ1 is missing resource definition.") super().__init__( name=name, - backend=BioShakeBackend(port=port, supports_active_cooling=True), + driver=BioShakeBackend(port=port, supports_active_cooling=True), size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._backend) - self.shaker = ShakingCapability(backend=self._backend) + self.tc = TemperatureControlCapability(backend=self._driver) + self.shaker = ShakingCapability(backend=self._driver) self._capabilities = [self.tc, self.shaker] @@ -448,15 +448,15 @@ def __init__(self, name: str, port: str): raise NotImplementedError("BioShakeQ2 is missing resource definition.") super().__init__( name=name, - backend=BioShakeBackend(port=port, supports_active_cooling=True), + driver=BioShakeBackend(port=port, supports_active_cooling=True), size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._backend) - self.shaker = ShakingCapability(backend=self._backend) + self.tc = TemperatureControlCapability(backend=self._driver) + self.shaker = ShakingCapability(backend=self._driver) self._capabilities = [self.tc, self.shaker] @@ -470,14 +470,14 @@ def __init__(self, name: str, port: str): raise NotImplementedError("Heatplate is missing resource definition.") super().__init__( name=name, - backend=BioShakeBackend(port=port), + driver=BioShakeBackend(port=port), size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._backend) + self.tc = TemperatureControlCapability(backend=self._driver) self._capabilities = [self.tc] @@ -488,12 +488,12 @@ def __init__(self, name: str, port: str): raise NotImplementedError("ColdPlate is missing resource definition.") super().__init__( name=name, - backend=BioShakeBackend(port=port, supports_active_cooling=True), + driver=BioShakeBackend(port=port, supports_active_cooling=True), size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._backend) + self.tc = TemperatureControlCapability(backend=self._driver) self._capabilities = [self.tc] diff --git a/pylabrobot/thermo_fisher/cytomat/backend.py b/pylabrobot/thermo_fisher/cytomat/backend.py index c2b81fa03fa..51a18cbc25b 100644 --- a/pylabrobot/thermo_fisher/cytomat/backend.py +++ b/pylabrobot/thermo_fisher/cytomat/backend.py @@ -16,7 +16,7 @@ from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend from pylabrobot.capabilities.shaking.backend import ShakerBackend from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend -from pylabrobot.device import DeviceBackend +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial from pylabrobot.resources import Plate, PlateCarrier, PlateHolder from pylabrobot.thermo_fisher.cytomat.constants import ( @@ -59,6 +59,7 @@ class CytomatBackend( TemperatureControllerBackend, HumidityControllerBackend, ShakerBackend, + Driver, ): default_baud = 9600 serial_message_encoding = "utf-8" @@ -102,14 +103,14 @@ def __init__(self, model: Union[CytomatType, str], port: str): ) async def setup(self): - await DeviceBackend.setup(self) + await Driver.setup(self) await self.io.setup() await self.initialize() await self.wait_for_task_completion() async def stop(self): await self.io.stop() - await DeviceBackend.stop(self) + await Driver.stop(self) async def set_racks(self, racks: List[PlateCarrier]): self._racks = racks @@ -436,7 +437,7 @@ async def get_temperature(self) -> float: def serialize(self) -> dict: return { - **DeviceBackend.serialize(self), + **Driver.serialize(self), "model": self.model.value, "port": self.io.port, } diff --git a/pylabrobot/thermo_fisher/cytomat/cytomat.py b/pylabrobot/thermo_fisher/cytomat/cytomat.py index de53a2bf7f6..ed45c7799ea 100644 --- a/pylabrobot/thermo_fisher/cytomat/cytomat.py +++ b/pylabrobot/thermo_fisher/cytomat/cytomat.py @@ -26,7 +26,7 @@ class NoFreeSiteError(Exception): class Cytomat(Resource, Device): _racks: List[PlateCarrier] - _backend: CytomatBackend + _driver: CytomatBackend loading_tray: PlateHolder retrieval: AutomatedRetrievalCapability tc: TemperatureControlCapability @@ -36,7 +36,7 @@ class Cytomat(Resource, Device): def __init__( self, name: str, - backend: CytomatBackend, + driver: CytomatBackend, racks: List[PlateCarrier], loading_tray_location: Coordinate, size_x: float = 0, @@ -57,8 +57,8 @@ def __init__( category=category, model=model, ) - Device.__init__(self, backend=backend) - self._backend: CytomatBackend = backend + Device.__init__(self, driver=driver) + self._driver: CytomatBackend = driver self.loading_tray = PlateHolder( name=f"{name}_tray", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0 @@ -69,14 +69,14 @@ def __init__( for rack in self._racks: self.assign_child_resource(rack, location=None) - self.retrieval = AutomatedRetrievalCapability(backend=backend) - self.tc = TemperatureControlCapability(backend=backend) - self.humidity = HumidityControlCapability(backend=backend) + self.retrieval = AutomatedRetrievalCapability(backend=driver) + self.tc = TemperatureControlCapability(backend=driver) + self.humidity = HumidityControlCapability(backend=driver) caps = [self.tc, self.humidity, self.retrieval] - if backend.model != CytomatType.C5C: - self.shaker = ShakingCapability(backend=backend) + if driver.model != CytomatType.C5C: + self.shaker = ShakingCapability(backend=driver) caps.append(self.shaker) self._capabilities = caps @@ -87,7 +87,7 @@ def racks(self) -> List[PlateCarrier]: async def setup(self, **backend_kwargs): await super().setup() - await self._backend.set_racks(self._racks) + await self._driver.set_racks(self._racks) def get_num_free_sites(self) -> int: return sum([len(rack.get_free_sites()) for rack in self._racks]) diff --git a/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py index 8890cc4ca8e..3f4c40e9fdf 100644 --- a/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py +++ b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py @@ -16,7 +16,7 @@ from pylabrobot.capabilities.humidity_controlling.backend import HumidityControllerBackend from pylabrobot.capabilities.shaking.backend import ShakerBackend from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend -from pylabrobot.device import DeviceBackend +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial from pylabrobot.resources import Plate, PlateHolder from pylabrobot.resources.carrier import PlateCarrier @@ -29,6 +29,7 @@ class HeraeusCytomatBackend( TemperatureControllerBackend, HumidityControllerBackend, ShakerBackend, + Driver, ): """ Backend for legacy (Heraeus) Cytomats. @@ -66,7 +67,7 @@ def __init__(self, port: str): ) async def setup(self): - await DeviceBackend.setup(self) + await Driver.setup(self) try: await self.io.setup() except serial.SerialException as e: @@ -106,7 +107,7 @@ async def setup(self): async def stop(self): await self.io.stop() - await DeviceBackend.stop(self) + await Driver.stop(self) async def set_racks(self, racks: List[PlateCarrier]): self._racks = racks @@ -234,7 +235,7 @@ async def close_door(self): def serialize(self) -> dict: return { - **DeviceBackend.serialize(self), + **Driver.serialize(self), "port": self.io.port, } From 2e8dc3d293cd56f36ae0bdcd592393925768932d Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 26 Mar 2026 00:31:16 -0700 Subject: [PATCH 08/69] Split all backends into Driver + CapabilityBackend adapters Every monolithic backend that extended both a CapabilityBackend and Driver is now split into: - Driver: owns I/O, connection lifecycle, device-level ops - CapabilityBackend: protocol translation, encodes capability calls into driver commands Devices split: HepaFan, BioShake, Pico, Opentrons TempModule, Hamilton HeaterShaker, Hamilton TiltModule, Keyence BarcodeScanner, XPeel, SCILA, MettlerToledo, A4S, VSpin/Access2, CLARIOstar, SpectraMax 384+/M5. Also: CapabilityBackend gains _on_setup/_on_stop hooks, Capability._on_setup calls backend._on_setup, updated creating-capabilities.md, updated all legacy wrappers. Co-Authored-By: Claude Opus 4.6 (1M context) --- creating-capabilities.md | 474 +++++++---- docs/api/pylabrobot.tilting.rst | 3 +- pylabrobot/agilent/__init__.py | 2 +- pylabrobot/agilent/vspin/__init__.py | 2 +- pylabrobot/agilent/vspin/vspin.py | 457 ++++++----- pylabrobot/azenta/__init__.py | 4 +- pylabrobot/azenta/a4s.py | 175 ++-- pylabrobot/azenta/xpeel.py | 141 ++-- pylabrobot/bmg_labtech/__init__.py | 9 +- pylabrobot/bmg_labtech/clariostar.py | 434 ---------- pylabrobot/bmg_labtech/clariostar/__init__.py | 5 + .../clariostar/absorbance_backend.py | 104 +++ .../bmg_labtech/clariostar/clariostar.py | 61 ++ pylabrobot/bmg_labtech/clariostar/driver.py | 200 +++++ .../clariostar/fluorescence_backend.py | 29 + .../clariostar/luminescence_backend.py | 64 ++ pylabrobot/capabilities/capability.py | 8 +- pylabrobot/device.py | 3 - pylabrobot/hamilton/heater_shaker/__init__.py | 6 +- pylabrobot/hamilton/heater_shaker/backend.py | 74 +- .../hamilton/heater_shaker/heater_shaker.py | 17 +- pylabrobot/hamilton/only_fans/__init__.py | 6 +- pylabrobot/hamilton/only_fans/backend.py | 253 +++--- pylabrobot/hamilton/only_fans/hepa_fan.py | 10 +- pylabrobot/hamilton/tilt_module/__init__.py | 6 +- pylabrobot/hamilton/tilt_module/backend.py | 75 +- .../hamilton/tilt_module/tilt_module.py | 10 +- pylabrobot/inheco/scila/__init__.py | 2 +- pylabrobot/inheco/scila/scila.py | 11 +- pylabrobot/inheco/scila/scila_backend.py | 75 +- .../inheco/scila/scila_backend_tests.py | 180 ++-- pylabrobot/keyence/__init__.py | 6 +- pylabrobot/keyence/keyence_backend.py | 56 +- pylabrobot/keyence/keyence_barcode_scanner.py | 20 + .../legacy/barcode_scanners/__init__.py | 2 +- .../barcode_scanners/keyence/__init__.py | 4 +- .../keyence/keyence_backend.py | 32 +- pylabrobot/legacy/centrifuge/vspin_backend.py | 93 ++- .../heating_shaking/bioshake_backend.py | 44 +- .../heating_shaking/hamilton_backend.py | 40 +- .../molecular_devices/pico/backend.py | 39 +- .../molecular_devices/pico/backend_tests.py | 14 +- .../only_fans/hamilton_hepa_fan_backend.py | 17 +- pylabrobot/legacy/peeling/xpeel_backend.py | 51 +- .../bmg_labtech/clario_star_backend.py | 41 +- .../molecular_devices/backend.py | 75 +- .../spectramax_384_plus_backend.py | 20 +- .../spectramax_m5_backend.py | 4 +- .../legacy/scales/mettler_toledo_backend.py | 58 +- pylabrobot/legacy/sealing/a4s_backend.py | 40 +- .../storage/inheco/scila/scila_backend.py | 47 +- .../inheco/scila/scila_backend_tests.py | 2 +- .../opentrons_backend.py | 26 +- .../opentrons_backend_usb.py | 26 +- pylabrobot/legacy/tilting/__init__.py | 2 +- pylabrobot/legacy/tilting/hamilton_backend.py | 5 +- pylabrobot/mettler_toledo/__init__.py | 6 +- pylabrobot/mettler_toledo/mettler_toledo.py | 80 +- pylabrobot/molecular_devices/__init__.py | 11 +- .../imageXpress/pico/__init__.py | 2 +- .../imageXpress/pico/backend.py | 171 ++-- .../imageXpress/pico/pico.py | 18 +- .../molecular_devices/spectramax/__init__.py | 12 +- .../molecular_devices/spectramax/backend.py | 235 +++--- .../spectramax/backend_tests.py | 766 ++++-------------- .../spectramax/spectramax_384_plus.py | 36 +- .../spectramax/spectramax_m5.py | 218 ++--- pylabrobot/opentrons/__init__.py | 6 +- .../opentrons/temperature_module/__init__.py | 9 +- .../opentrons/temperature_module/backend.py | 137 ---- .../temperature_module/http_driver.py | 65 ++ .../temperature_module/temperature_module.py | 23 +- .../temperature_module/usb_driver.py | 88 ++ pylabrobot/qinstruments/__init__.py | 4 +- pylabrobot/qinstruments/bioshake.py | 158 ++-- 75 files changed, 2981 insertions(+), 2728 deletions(-) delete mode 100644 pylabrobot/bmg_labtech/clariostar.py create mode 100644 pylabrobot/bmg_labtech/clariostar/__init__.py create mode 100644 pylabrobot/bmg_labtech/clariostar/absorbance_backend.py create mode 100644 pylabrobot/bmg_labtech/clariostar/clariostar.py create mode 100644 pylabrobot/bmg_labtech/clariostar/driver.py create mode 100644 pylabrobot/bmg_labtech/clariostar/fluorescence_backend.py create mode 100644 pylabrobot/bmg_labtech/clariostar/luminescence_backend.py create mode 100644 pylabrobot/keyence/keyence_barcode_scanner.py delete mode 100644 pylabrobot/opentrons/temperature_module/backend.py create mode 100644 pylabrobot/opentrons/temperature_module/http_driver.py create mode 100644 pylabrobot/opentrons/temperature_module/usb_driver.py diff --git a/creating-capabilities.md b/creating-capabilities.md index c3fda8a3472..e01834268a6 100644 --- a/creating-capabilities.md +++ b/creating-capabilities.md @@ -1,63 +1,121 @@ # Creating capabilities -This document describes how to create new capabilities and how to migrate legacy -`Machine`/`MachineBackend` modules to the new `Device`/`Capability`/`DeviceBackend` architecture. +This document describes how to create new capabilities and migrate devices to the +`Device`/`Driver`/`CapabilityBackend`/`Capability` architecture. -## Architecture overview - -**Old (legacy):** A `Machine` owns a single `MachineBackend`. The frontend class contains all -the logic and calls backend methods directly. +## Architecture ``` -Machine (frontend) - └── MachineBackend (abstract, one big interface) - └── ConcreteBackend (vendor implementation) +Device (frontend — user-facing API) + ├── _driver: Driver (hardware I/O, lifecycle, device-level ops) + ├── _capabilities: [Capability, ...] + │ └── Capability (frontend logic for one concern) + │ └── backend: CapabilityBackend (protocol translation, uses _driver) ``` -**New:** A `Device` owns a `DeviceBackend` and one or more `Capability` objects. Each capability -is a focused interface (e.g. shaking, temperature control) with its own backend type. Frontend -logic lives in the capability, not the device. +### Lifecycle ``` -Device - ├── ShakerBackend (DeviceBackend subclass) - ├── ShakingCapability (owns a reference to the backend) - └── TemperatureControlCapability (owns a reference to the same backend) +Device.setup() + → driver.setup() # open connection, initialize hardware + → for cap in capabilities: + cap._on_setup() # Capability._on_setup() + → cap.backend._on_setup() # CapabilityBackend._on_setup() + → cap._setup_finished = True + +Device.stop() + → for cap in reversed(capabilities): + cap._on_stop() # Capability._on_stop() + → cap.backend._on_stop() # CapabilityBackend._on_stop() + → cap._setup_finished = False + → driver.stop() # close connection ``` +### What goes where + +| Layer | Responsibility | Examples | +|-------|---------------|----------| +| **Driver** | I/O (serial, USB, gRPC), connection lifecycle (`setup`/`stop`), device-level operations that don't fit any capability. Exposes **generic** methods like `send(bytes)`, `send_command(str)`, `run_measurement(payload)`. | `send_command()`, `open_door()`, `reset()`, `home()`, `get_configuration()` | +| **CapabilityBackend** | Protocol translation — encodes capability-level operations into driver commands. Holds capability-specific config. Has `_on_setup`/`_on_stop` hooks for initialization after driver connects. | `start_shaking()` → `driver.send_command("shakeOn")`, objective/filter cube config | +| **Capability** | User-facing API, validation, orchestration, convenience methods | `shake(speed, duration)` → calls backend + sleep + stop | +| **Device** | Wires driver + capabilities. Manages lifecycle. | Creates driver and backends in `__init__`, registers `_capabilities` | + ### Key classes -| Class | Location | Role | +| Class | Location | Base | |-------|----------|------| -| `DeviceBackend` | `pylabrobot.device` | Base for all new backends. Abstract `setup()` and `stop()`. | -| `Device` | `pylabrobot.device` | Base for all new devices. Manages capabilities lifecycle. | -| `Capability` | `pylabrobot.capabilities.capability` | Base for capabilities. Owned by a `Device`. | -| `MachineBackend` | `pylabrobot.legacy.machines.backend` | Legacy backend base. Independent from `DeviceBackend`. | -| `Machine` | `pylabrobot.legacy.machines.machine` | Legacy frontend base. | +| `Driver` | `pylabrobot.device` | `SerializableMixin, ABC` — abstract `setup()`, `stop()` | +| `Device` | `pylabrobot.device` | `SerializableMixin, ABC` — owns `_driver: Driver`, `_capabilities: List[Capability]` | +| `CapabilityBackend` | `pylabrobot.capabilities.capability` | `ABC` — has `_on_setup()`, `_on_stop()` hooks | +| `Capability` | `pylabrobot.capabilities.capability` | `ABC` — owns `backend: CapabilityBackend`, has `_on_setup()`, `_on_stop()` | + +### Common mistakes + +**Driver mirrors capability interface (WRONG):** +```python +class MyDriver(Driver): + async def set_temperature(self, temperature: float): # NO — this is a capability method + self.io.write(f"SET {temperature}") + +class MyTempBackend(TemperatureControllerBackend): + async def set_temperature(self, temperature: float): + await self._driver.set_temperature(temperature) # pointless delegation +``` + +**Backend encodes protocol (RIGHT):** +```python +class MyDriver(Driver): + async def send_command(self, cmd: str): # generic wire method + await self.io.write(cmd.encode()) + +class MyTempBackend(TemperatureControllerBackend): + async def set_temperature(self, temperature: float): + await self._driver.send_command(f"SET {temperature}") # protocol lives here +``` + +The driver is the wire. The backend is the protocol. If a driver method has the same name +as a capability method, something is wrong. + +**Initialization in driver.setup() vs backend._on_setup():** +Hardware-specific init that requires the driver to be connected (e.g. "initialize shaker +drive", "configure objectives") belongs in `CapabilityBackend._on_setup()`, not +`Driver.setup()`. The driver's `setup()` should only open the connection. ## Creating a new capability -### 1. Define the backend +### 1. Define the capability backend (abstract) -Create an abstract backend in `pylabrobot/capabilities//backend.py`: +`pylabrobot/capabilities//backend.py`: ```python from abc import ABCMeta, abstractmethod -from pylabrobot.device import DeviceBackend +from pylabrobot.capabilities.capability import CapabilityBackend -class ShakerBackend(DeviceBackend, metaclass=ABCMeta): +class ShakerBackend(CapabilityBackend, metaclass=ABCMeta): @abstractmethod async def start_shaking(self, speed: float): ... @abstractmethod async def stop_shaking(self): ... + + @property + @abstractmethod + def supports_locking(self) -> bool: ... + + @abstractmethod + async def lock_plate(self): ... + + @abstractmethod + async def unlock_plate(self): ... ``` -The backend defines *what* operations are possible. Keep it minimal — one capability, one concern. +One capability, one concern. Only abstract methods for the operations this capability supports. +No `setup()`/`stop()` — use `_on_setup()`/`_on_stop()` (inherited from `CapabilityBackend`) +for initialization that must happen after the driver is connected. -### 2. Define the capability +### 2. Define the capability (frontend) -Create the capability in `pylabrobot/capabilities//.py`: +`pylabrobot/capabilities//.py`: ```python from pylabrobot.capabilities.capability import Capability @@ -66,9 +124,10 @@ from .backend import ShakerBackend class ShakingCapability(Capability): def __init__(self, backend: ShakerBackend): super().__init__(backend=backend) - self.backend: ShakerBackend = backend + self.backend: ShakerBackend = backend # narrow the type async def shake(self, speed: float, duration: float = None): + """Convenience: shake for a duration then stop.""" await self.backend.start_shaking(speed=speed) if duration: await asyncio.sleep(duration) @@ -76,158 +135,301 @@ class ShakingCapability(Capability): ``` Frontend logic (validation, orchestration, convenience methods) lives here, not in the backend. +The `self.backend: ShakerBackend = backend` line narrows the type from `CapabilityBackend`. -### 3. Implement vendor backends +### 3. Export via `__init__.py` -In `pylabrobot//`, create a concrete backend and device: +`pylabrobot/capabilities//__init__.py`: ```python -from pylabrobot.capabilities.shaking import ShakerBackend, ShakingCapability -from pylabrobot.device import Device +from .backend import ShakerBackend +from . import ShakingCapability +``` -class MyVendorShakerBackend(ShakerBackend): - async def setup(self): ... - async def stop(self): ... - async def start_shaking(self, speed: float): ... - async def stop_shaking(self): ... +## Implementing a vendor device -class MyVendorShaker(Device): - def __init__(self, backend: MyVendorShakerBackend): - super().__init__(backend=backend) - self.shaking = ShakingCapability(backend=backend) - self._capabilities = [self.shaking] -``` +### Single-capability device -## Making legacy code wrap new code +`pylabrobot//backend.py`: -When a legacy module already exists, the goal is to move the *implementation* into capabilities -while keeping the legacy frontend and backend interfaces unchanged. Users of the old API should -not need to change anything. +```python +from pylabrobot.capabilities.fan_control import FanBackend +from pylabrobot.device import Driver -### Principles +class MyFanDriver(Driver): + """Owns the hardware connection. Knows how to send bytes on the wire.""" + + def __init__(self, port: str): + self.io = Serial(port=port, baudrate=9600, ...) + + async def setup(self): + await self.io.setup() -1. **Legacy types don't change.** The old `MachineBackend` subclass keeps its name, its methods, - and its import path. Existing user code that subclasses it must keep working. + async def stop(self): + await self.io.stop() -2. **Implementation moves to capabilities.** The legacy frontend delegates to capability objects - internally. This avoids duplicating logic in both old and new code paths. + async def send(self, command: bytes): + """Send raw bytes and read response.""" + await self.io.write(command) + return await self.io.read(64) -3. **`MachineBackend` and `DeviceBackend` are independent hierarchies.** They are structurally - similar but intentionally separate. Legacy backends never inherit from `DeviceBackend`. -4. **Always use adapters.** Even when the old and new backend signatures happen to match today, - use an adapter. This protects against silent breakage if the new capability backend changes - later. The adapter is the single point where old meets new. +class MyFanFanBackend(FanBackend): + """Translates FanBackend interface into driver commands. + + This is where protocol encoding lives — the backend knows that + turn_on means sending specific byte sequences via the driver. + """ + + def __init__(self, driver: MyFanDriver): + self._driver = driver + + async def turn_on(self, intensity: int) -> None: + await self._driver.send(b"\x01" + bytes([intensity])) + + async def turn_off(self) -> None: + await self._driver.send(b"\x00") +``` + +`pylabrobot//.py`: + +```python +from pylabrobot.capabilities.fan_control import FanControlCapability +from pylabrobot.device import Device +from .backend import MyFanDriver, MyFanFanBackend + +class MyFan(Device): + def __init__(self, port: str): + driver = MyFanDriver(port=port) + super().__init__(driver=driver) + self._driver: MyFanDriver = driver + self.fan = FanControlCapability(backend=MyFanFanBackend(driver)) + self._capabilities = [self.fan] +``` -### Adapter pattern +### Multi-capability device (shared driver) -Every legacy frontend that delegates to a capability needs an adapter. The adapter: -- Implements the new capability backend interface (`DeviceBackend` subclass) -- Wraps a legacy backend instance and delegates to it -- Translates between old and new signatures if they differ -- Has no-op `setup()`/`stop()` since lifecycle is managed by the legacy `Machine` +When one device supports multiple capabilities, they share a single driver: ```python -# In the legacy frontend module (e.g. pylabrobot/legacy/shaking/shaker.py) +class BioShakeDriver(Driver): + """Serial driver. Owns I/O, device-level ops (reset, home).""" -from pylabrobot.capabilities.shaking import ShakerBackend as _NewShakerBackend, ShakingCapability + def __init__(self, port: str): + self.io = Serial(port=port, baudrate=9600, ...) + + async def setup(self, skip_home: bool = False): + await self.io.setup() + if not skip_home: + await self.reset() + await self.home() + + async def stop(self): + await self.io.stop() + + async def send_command(self, cmd: str) -> Optional[str]: + """Send an ASCII command, return parsed response.""" + ... + + async def reset(self): + """Device-level reset — not a capability.""" + ... + + async def home(self): + """Device-level homing — not a capability.""" + ... + + +class BioShakeShakerBackend(ShakerBackend): + """Encodes shaking protocol using the driver.""" + + def __init__(self, driver: BioShakeDriver): + self._driver = driver -class _ShakingAdapter(_NewShakerBackend): - """Adapts a legacy ShakerBackend to the new ShakerBackend interface.""" - def __init__(self, legacy: ShakerBackend): - self._legacy = legacy - async def setup(self): pass - async def stop(self): pass async def start_shaking(self, speed: float): - await self._legacy.start_shaking(speed) + await self._driver.send_command(f"setShakeTargetSpeed{int(speed)}") + await self._driver.send_command("shakeOn") + async def stop_shaking(self): - await self._legacy.stop_shaking() - @property - def supports_locking(self) -> bool: - return self._legacy.supports_locking - async def lock_plate(self): - await self._legacy.lock_plate() - async def unlock_plate(self): - await self._legacy.unlock_plate() - -class Shaker(Machine): - def __init__(self, backend: ShakerBackend): # legacy ShakerBackend - super().__init__(backend=backend) - self._cap = ShakingCapability(backend=_ShakingAdapter(backend)) + await self._driver.send_command("shakeOff") - async def shake(self, speed, duration=None): - await self._cap.shake(speed=speed, duration=duration) -``` + ... + + +class BioShakeTemperatureBackend(TemperatureControllerBackend): + """Encodes temperature protocol using the same driver.""" -### One-to-many split (e.g. PlateReader) + def __init__(self, driver: BioShakeDriver, supports_active_cooling: bool = False): + self._driver = driver + self._supports_active_cooling = supports_active_cooling -When the old backend is a "god object" that gets split into multiple capabilities: + async def set_temperature(self, temperature: float): + await self._driver.send_command(f"setTempTarget{int(temperature * 10)}") + await self._driver.send_command("tempOn") + ... + + +class BioShake3000T(PlateHolder, Device): + def __init__(self, name: str, port: str): + driver = BioShakeDriver(port=port) + PlateHolder.__init__(self, name=name, ...) + Device.__init__(self, driver=driver) + self._driver: BioShakeDriver = driver + self.tc = TemperatureControlCapability(backend=BioShakeTemperatureBackend(driver)) + self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) + self._capabilities = [self.tc, self.shaker] ``` -Old: PlateReaderBackend(MachineBackend) - read_absorbance(), read_fluorescence(), read_luminescence(), open(), close() -New: AbsorbanceBackend(DeviceBackend) with read_absorbance() - FluorescenceBackend(DeviceBackend) with read_fluorescence() - LuminescenceBackend(DeviceBackend) with read_luminescence() +### Backend `_on_setup` / `_on_stop` + +If a backend needs to do work after the driver connects (e.g. query hardware configuration), +override `_on_setup()`: + +```python +class PicoMicroscopyBackend(MicroscopyBackend): + def __init__(self, driver: PicoDriver, objectives=None, filter_cubes=None): + self._driver = driver + self._objectives = objectives or {} + self._filter_cubes = filter_cubes or {} + + async def _on_setup(self): + """Configure objectives and filter cubes after driver connects.""" + for pos, obj in self._objectives.items(): + await self.change_objective(pos, obj) + for pos, mode in self._filter_cubes.items(): + await self.change_filter_cube(pos, mode) ``` -The old `PlateReaderBackend` has `read_absorbance()` but is not an `AbsorbanceBackend`. You can't -pass it directly to `AbsorbanceCapability`. Use **adapters** in the legacy frontend: +This is called automatically by `Capability._on_setup()` → `backend._on_setup()` during +`Device.setup()`, after `driver.setup()` has completed. + +### Device-level operations + +Operations that don't fit any capability stay on the driver. Users access them via `_driver`: ```python -# pylabrobot/legacy/plate_reading/plate_reader.py +class PicoDriver(Driver): + async def open_door(self): ... + async def close_door(self): ... + async def get_configuration(self) -> dict: ... + +# Usage: +pico = Pico(name="pico", host="192.168.1.100") +await pico.setup() +await pico._driver.open_door() +``` -class _AbsorbanceAdapter(AbsorbanceBackend): - """Adapts a legacy PlateReaderBackend to the AbsorbanceBackend interface.""" - def __init__(self, legacy: PlateReaderBackend): - self._legacy = legacy +### Chatterbox backends (testing) - async def setup(self): pass # lifecycle managed by the legacy Machine - async def stop(self): pass +Chatterbox backends are pure `CapabilityBackend` subclasses with no driver. They return +dummy data for device-free testing: - async def read_absorbance(self, plate, wells, wavelength): - # translate between old and new signatures if needed - return await self._legacy.read_absorbance(plate, wells, wavelength) +```python +class MyFanChatterboxBackend(FanBackend): + """No-op backend for testing.""" + async def turn_on(self, intensity: int) -> None: + pass -class PlateReader(Machine): - def __init__(self, backend: PlateReaderBackend): - super().__init__(backend=backend) - self._absorbance = AbsorbanceCapability(backend=_AbsorbanceAdapter(backend)) - self._fluorescence = FluorescenceCapability(backend=_FluorescenceAdapter(backend)) - self._luminescence = LuminescenceCapability(backend=_LuminescenceAdapter(backend)) + async def turn_off(self) -> None: + pass ``` -Adapters belong in the legacy layer. They are the only place that knows about both the old and -new interfaces. If the new backend signature changes later, you update the adapter — the old -`PlateReaderBackend` interface is unaffected. +## Naming conventions + +| Thing | Pattern | Example | +|-------|---------|---------| +| Driver | `Driver` | `BioShakeDriver`, `PicoDriver` | +| Capability backend | `Backend` | `BioShakeShakerBackend`, `PicoMicroscopyBackend` | +| Chatterbox backend | `ChatterboxBackend` or `ChatterboxBackend` | `HamiltonHepaFanChatterboxBackend` | +| Capability (abstract) | `Capability` | `ShakingCapability`, `FanControlCapability` | +| Capability backend (abstract) | `Backend` | `ShakerBackend`, `FanBackend` | +| Device | `` or product name | `HamiltonHepaFan`, `BioShake3000T`, `Pico` | -### Case 3: Signature mismatch +## File layout -When the old and new backends have the same method name but different signatures: +For simple devices, driver and backends can live in one file. For complex devices or +when a vendor directory has multiple devices, split into separate files. +**Simple (single file):** ``` -Old: read_absorbance(plate, wells, wavelength) -> List[Dict] -New: read_absorbance(plate, wells, wavelength) -> List[AbsorbanceResult] +pylabrobot// + __init__.py + backend.py # Driver + CapabilityBackend(s) in one file + .py # Device frontend ``` -This is handled the same way as Case 2 — the adapter translates: +**Complex (split):** +``` +pylabrobot// + __init__.py + driver.py # Driver only + _backend.py # one file per CapabilityBackend + .py # Device frontend +``` -```python -class _AbsorbanceAdapter(AbsorbanceBackend): - async def read_absorbance(self, plate, wells, wavelength) -> List[AbsorbanceResult]: - dicts = await self._legacy.read_absorbance(plate, wells, wavelength) - return [AbsorbanceResult(data=d["data"], wavelength=wavelength, ...) for d in dicts] +**Capability definitions (always this layout):** ``` +pylabrobot/capabilities// + __init__.py # exports backend + capability + backend.py # abstract CapabilityBackend + .py # Capability frontend +``` + +## Checklist for splitting an existing monolithic backend + +When migrating an existing `class FooBackend(SomeCapabilityBackend, Driver)` to the split +architecture, follow these steps: + +1. **Read the existing backend class.** Identify: I/O setup, generic send/receive methods, + device-level ops (door, reset, home), and capability methods. +2. **Create the Driver.** Move I/O, `setup()`/`stop()`, generic send methods, device-level ops, + and `serialize()`. The driver's `setup()` should only open the connection — move any + capability-specific init to `_on_setup()` on the backend. +3. **Create the CapabilityBackend(s).** Move capability methods. Each backend gets + `__init__(self, driver: FooDriver)` and stores `self._driver = driver`. Protocol encoding + (building command strings, byte payloads) lives here, not on the driver. +4. **Update the Device.** Create driver + backend(s) in `__init__`, wire `_capabilities`. +5. **Update `__init__.py` exports.** Remove old class name, add new driver + backend names. +6. **Update legacy wrappers.** Check `pylabrobot/legacy/` for files that import the old class. + Update them to create driver + backend(s) internally. Run + `rg 'OldClassName' pylabrobot/legacy/` to find them. +7. **Preserve docstrings.** Do not remove existing docstrings when moving methods between classes. +8. **Smoke test.** `python -c "from pylabrobot. import ..."` to verify imports. -### Summary +## Making legacy code wrap new code -| Situation | Fix | -|-----------|-----| -| 1:1 mapping, same signatures | Adapter in legacy frontend (protects against future divergence) | -| 1:N split | Adapter per capability in the legacy frontend | -| Signature mismatch | Adapter that translates between old and new signatures | +When a legacy `Machine`/`MachineBackend` module already exists, the goal is to move the +*implementation* into the new architecture while keeping the legacy API unchanged. + +### Principles -In all cases, the adapter lives in the legacy layer and is the only code that knows about both -the old and new interfaces. +1. **Legacy types don't change.** The old `MachineBackend` subclass keeps its name and import path. +2. **Implementation moves to new code.** The legacy wrapper creates a driver + backends internally. +3. **`MachineBackend` and `Driver` are independent hierarchies.** Legacy backends never inherit from `Driver`. + +### Pattern + +```python +# pylabrobot/legacy//_backend.py + +from pylabrobot..backend import MyDriver, MyShakerBackend +from pylabrobot.legacy..backend import LegacyShakerBackend + +class LegacyMyDevice(LegacyShakerBackend): + def __init__(self, port: str): + self._driver = MyDriver(port=port) + self._shaker = MyShakerBackend(self._driver) + + async def setup(self): + await self._driver.setup() + await self._shaker._on_setup() + + async def stop(self): + await self._shaker._on_stop() + await self._driver.stop() + + async def start_shaking(self, speed: float): + await self._shaker.start_shaking(speed) +``` diff --git a/docs/api/pylabrobot.tilting.rst b/docs/api/pylabrobot.tilting.rst index 564601d2765..d69dd256301 100644 --- a/docs/api/pylabrobot.tilting.rst +++ b/docs/api/pylabrobot.tilting.rst @@ -23,4 +23,5 @@ Backends chatterbox.TilterChatterboxBackend tilter_backend.TilterBackend - hamilton_backend.HamiltonTiltModuleBackend + hamilton_backend.HamiltonTiltModuleDriver + hamilton_backend.HamiltonTiltModuleTilterBackend diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py index e9e9912b1c2..7eed8666cb7 100644 --- a/pylabrobot/agilent/__init__.py +++ b/pylabrobot/agilent/__init__.py @@ -8,4 +8,4 @@ SynergyH1, SynergyH1Backend, ) -from .vspin import Access2, Access2Backend, VSpin, VSpinBackend +from .vspin import Access2, Access2Driver, VSpin, VSpinCentrifugeBackend, VSpinDriver diff --git a/pylabrobot/agilent/vspin/__init__.py b/pylabrobot/agilent/vspin/__init__.py index 50891a35b62..52a4b6f576f 100644 --- a/pylabrobot/agilent/vspin/__init__.py +++ b/pylabrobot/agilent/vspin/__init__.py @@ -1 +1 @@ -from .vspin import Access2, Access2Backend, VSpin, VSpinBackend +from .vspin import Access2, Access2Driver, VSpin, VSpinCentrifugeBackend, VSpinDriver diff --git a/pylabrobot/agilent/vspin/vspin.py b/pylabrobot/agilent/vspin/vspin.py index 91629957731..d2db1114292 100644 --- a/pylabrobot/agilent/vspin/vspin.py +++ b/pylabrobot/agilent/vspin/vspin.py @@ -9,6 +9,7 @@ from dataclasses import dataclass from typing import Optional +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.centrifuging import CentrifugeBackend as _NewCentrifugeBackend from pylabrobot.capabilities.centrifuging import CentrifugingCapability from pylabrobot.capabilities.centrifuging.errors import ( @@ -21,7 +22,6 @@ from pylabrobot.device import Device, Driver from pylabrobot.io.ftdi import FTDI from pylabrobot.resources import Coordinate, Resource, ResourceHolder -from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin logger = logging.getLogger(__name__) @@ -60,16 +60,23 @@ def _save_vspin_calibrations(device_id, remainder: int): FULL_ROTATION: int = 8000 - bucket_1_not_set_error = RuntimeError( "Bucket 1 position not set. " - "Please rotate the bucket to bucket 1 using VSpinBackend.go_to_position and " - "then calling VSpinBackend.set_bucket_1_position_to_current." + "Please rotate the bucket to bucket 1 using go_to_position and " + "then calling set_bucket_1_position_to_current." ) -class VSpinBackend(_NewCentrifugeBackend, Driver): - """Backend for the Agilent VSpin Centrifuge.""" +# --------------------------------------------------------------------------- +# VSpin Driver — FTDI I/O and hardware queries +# --------------------------------------------------------------------------- + + +class VSpinDriver(Driver): + """FTDI driver for the Agilent VSpin Centrifuge. + + Owns the USB connection, low-level command protocol, and hardware status queries. + """ def __init__(self, device_id: Optional[str] = None): """ @@ -79,116 +86,75 @@ def __init__(self, device_id: Optional[str] = None): """ super().__init__() self.io = FTDI(human_readable_device_name="Agilent VSpin Centrifuge", device_id=device_id) - self._bucket_1_remainder: Optional[int] = None - if device_id is not None: - self._bucket_1_remainder = _load_vspin_calibrations(device_id) + self.device_id = device_id async def setup(self): await self.io.setup() for _ in range(3): await self.configure_and_initialize() - await self._send_command(bytes.fromhex("aa002101ff21")) - await self._send_command(bytes.fromhex("aa002101ff21")) - await self._send_command(bytes.fromhex("aa01132034")) - await self._send_command(bytes.fromhex("aa002102ff22")) - await self._send_command(bytes.fromhex("aa02132035")) - await self._send_command(bytes.fromhex("aa002103ff23")) - await self._send_command(bytes.fromhex("aaff1a142d")) + await self.send_command(bytes.fromhex("aa002101ff21")) + await self.send_command(bytes.fromhex("aa002101ff21")) + await self.send_command(bytes.fromhex("aa01132034")) + await self.send_command(bytes.fromhex("aa002102ff22")) + await self.send_command(bytes.fromhex("aa02132035")) + await self.send_command(bytes.fromhex("aa002103ff23")) + await self.send_command(bytes.fromhex("aaff1a142d")) await self.io.set_baudrate(57600) await self.io.set_rts(True) await self.io.set_dtr(True) - await self._send_command(bytes.fromhex("aa01121f32")) - for _ in range(8): - await self._send_command(bytes.fromhex("aa0220ff0f30")) - await self._send_command(bytes.fromhex("aa0220df0f10")) - await self._send_command(bytes.fromhex("aa0220df0e0f")) - await self._send_command(bytes.fromhex("aa0220df0c0d")) - await self._send_command(bytes.fromhex("aa0220df0809")) - for _ in range(4): - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa02120317")) - for _ in range(5): - await self._send_command(bytes.fromhex("aa0226200048")) - await self._send_command(bytes.fromhex("aa0226000028")) - await self.lock_door() - - await self._send_command(bytes.fromhex("aa0226000028")) - - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa010001")) - await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) - await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) - await self._send_command(bytes.fromhex("aa01192842")) - - resp = 0x89 - while resp == 0x89: - resp = (await self._get_positions_and_tachometer()).status + async def stop(self): + await self.configure_and_initialize() + await self.io.stop() - # --- almost the same as go to position --- - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - new_position = (0).to_bytes(4, byteorder="little") # arbitrary - await self._send_command( - bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") - ) - # ----------------------------------------- + # -- low-level protocol -- - resp = 0x08 - while resp != 0x09: - resp = (await self._get_positions_and_tachometer()).status + async def _read_resp(self, timeout: float = 20) -> bytes: + data = b"" + end_byte_found = False + start_time = time.time() - await self._send_command(bytes.fromhex("aa0117021a")) + while True: + chunk = await self.io.read(25) + if chunk: + data += chunk + end_byte_found = data[-1] == 0x0D + if len(chunk) < 25 and end_byte_found: + break + else: + if end_byte_found or time.time() - start_time > timeout: + break + await asyncio.sleep(0.0001) - await self.lock_door() + logger.debug("Read %s", data.hex()) + return data - # If we have not set the calibration yet, load it now. - if self._bucket_1_remainder is None: - device_id = await self.io.get_serial() - self._bucket_1_remainder = _load_vspin_calibrations(device_id) + async def send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: + written = await self.io.write(bytes(cmd)) + if written != len(cmd): + raise RuntimeError("Failed to write all bytes") + return await self._read_resp(timeout=read_timeout) - @property - def bucket_1_remainder(self) -> int: - if self._bucket_1_remainder is None: - raise bucket_1_not_set_error - return self._bucket_1_remainder + async def configure_and_initialize(self): + await self.set_configuration_data() + await self.initialize() - async def set_bucket_1_position_to_current(self) -> None: - """Set the current position as bucket 1 position and save calibration.""" - current_position = await self.get_position() - device_id = await self.io.get_serial() - remainder = await self.get_home_position() - current_position - self._bucket_1_remainder = current_position % FULL_ROTATION - _save_vspin_calibrations(device_id, remainder) + async def set_configuration_data(self): + """Set the device configuration data.""" + await self.io.set_latency_timer(16) + await self.io.set_line_property(bits=8, stopbits=1, parity=0) + await self.io.set_flowctrl(0) + await self.io.set_baudrate(19200) - async def get_bucket_1_position(self) -> int: - """Get the bucket 1 position based on calibration.""" - if self._bucket_1_remainder is None: - raise bucket_1_not_set_error - home_position = await self.get_home_position() - bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder - current_position = await self.get_position() - bucket_1_position = ( - FULL_ROTATION - * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) - + bucket_1_position_mod_full_rotation - ) - return bucket_1_position + async def initialize(self): + await self.io.write(b"\x00" * 20) + for i in range(33): + packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 + await self.io.write(packet) + await self.send_command(bytes.fromhex("aaff0f0e")) - async def stop(self): - await self.configure_and_initialize() - await self.io.stop() + # -- hardware status queries -- class _StatusPositionTachometer(ctypes.LittleEndianStructure): _pack_ = 1 @@ -202,26 +168,26 @@ class _StatusPositionTachometer(ctypes.LittleEndianStructure): ("checksum", ctypes.c_uint8), ] - async def _get_positions_and_tachometer(self) -> _StatusPositionTachometer: - resp = await self._send_command(bytes.fromhex("aa010e0f")) + async def get_positions_and_tachometer(self) -> _StatusPositionTachometer: + resp = await self.send_command(bytes.fromhex("aa010e0f")) if len(resp) == 0: raise IOError("Empty status from centrifuge") - return VSpinBackend._StatusPositionTachometer.from_buffer_copy(resp) + return VSpinDriver._StatusPositionTachometer.from_buffer_copy(resp) async def get_position(self) -> int: - return (await self._get_positions_and_tachometer()).current_position # type: ignore + return (await self.get_positions_and_tachometer()).current_position # type: ignore async def get_tachometer(self) -> int: """Current speed in rpm.""" tack_to_rpm = -14.69320388 - return (await self._get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore + return (await self.get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore async def get_home_position(self) -> int: """Changes during a run, but the bucket 1 position relative to it does not.""" - return (await self._get_positions_and_tachometer()).home_position # type: ignore + return (await self.get_positions_and_tachometer()).home_position # type: ignore async def _get_status(self): - resp = await self._send_command(bytes.fromhex("aa020e10")) + resp = await self.send_command(bytes.fromhex("aa020e10")) if len(resp) == 0: raise IOError("Empty status from centrifuge. Is the machine on?") return resp @@ -238,88 +204,147 @@ async def get_door_locked(self) -> bool: resp = await self._get_status() return resp[2] & 0b0100 == 0 # type: ignore - # Centrifuge communication: read_resp, send - async def _read_resp(self, timeout: float = 20) -> bytes: - data = b"" - end_byte_found = False - start_time = time.time() +# --------------------------------------------------------------------------- +# VSpin Centrifuge Backend — protocol translation +# --------------------------------------------------------------------------- - while True: - chunk = await self.io.read(25) - if chunk: - data += chunk - end_byte_found = data[-1] == 0x0D - if len(chunk) < 25 and end_byte_found: - break - else: - if end_byte_found or time.time() - start_time > timeout: - break - await asyncio.sleep(0.0001) - logger.debug("Read %s", data.hex()) - return data +class VSpinCentrifugeBackend(_NewCentrifugeBackend): + """Translates CentrifugeBackend interface into VSpin driver commands.""" - async def _send_command(self, cmd: bytes, read_timeout=0.2) -> bytes: - written = await self.io.write(bytes(cmd)) + def __init__(self, driver: VSpinDriver): + self._driver = driver + self._bucket_1_remainder: Optional[int] = None + if driver.device_id is not None: + self._bucket_1_remainder = _load_vspin_calibrations(driver.device_id) - if written != len(cmd): - raise RuntimeError("Failed to write all bytes") - return await self._read_resp(timeout=read_timeout) + async def _on_setup(self): + driver = self._driver - async def configure_and_initialize(self): - await self.set_configuration_data() - await self.initialize() + await driver.send_command(bytes.fromhex("aa01121f32")) + for _ in range(8): + await driver.send_command(bytes.fromhex("aa0220ff0f30")) + await driver.send_command(bytes.fromhex("aa0220df0f10")) + await driver.send_command(bytes.fromhex("aa0220df0e0f")) + await driver.send_command(bytes.fromhex("aa0220df0c0d")) + await driver.send_command(bytes.fromhex("aa0220df0809")) + for _ in range(4): + await driver.send_command(bytes.fromhex("aa0226000028")) + await driver.send_command(bytes.fromhex("aa02120317")) + for _ in range(5): + await driver.send_command(bytes.fromhex("aa0226200048")) + await driver.send_command(bytes.fromhex("aa0226000028")) + await self.lock_door() - async def set_configuration_data(self): - """Set the device configuration data.""" - await self.io.set_latency_timer(16) - await self.io.set_line_property(bits=8, stopbits=1, parity=0) - await self.io.set_flowctrl(0) - await self.io.set_baudrate(19200) + await driver.send_command(bytes.fromhex("aa0226000028")) - async def initialize(self): - await self.io.write(b"\x00" * 20) - for i in range(33): - packet = b"\xaa" + bytes([i & 0xFF, 0x0E, 0x0E + (i & 0xFF)]) + b"\x00" * 8 - await self.io.write(packet) - await self._send_command(bytes.fromhex("aaff0f0e")) + await driver.send_command(bytes.fromhex("aa0117021a")) + await driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await driver.send_command(bytes.fromhex("aa0117041c")) + await driver.send_command(bytes.fromhex("aa01170119")) - # Centrifuge operations (CentrifugeBackend interface) + await driver.send_command(bytes.fromhex("aa010b0c")) + await driver.send_command(bytes.fromhex("aa010001")) + await driver.send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await driver.send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await driver.send_command(bytes.fromhex("aa01192842")) + + resp = 0x89 + while resp == 0x89: + resp = (await driver.get_positions_and_tachometer()).status + + # --- almost the same as go to position --- + await driver.send_command(bytes.fromhex("aa0117021a")) + await driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await driver.send_command(bytes.fromhex("aa0117041c")) + await driver.send_command(bytes.fromhex("aa01170119")) + + await driver.send_command(bytes.fromhex("aa010b0c")) + await driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + new_position = (0).to_bytes(4, byteorder="little") + await driver.send_command( + bytes.fromhex("aa01d497") + new_position + bytes.fromhex("c3f52800d71a000049") + ) + # ----------------------------------------- + + resp = 0x08 + while resp != 0x09: + resp = (await driver.get_positions_and_tachometer()).status + + await driver.send_command(bytes.fromhex("aa0117021a")) + + await self.lock_door() + + if self._bucket_1_remainder is None: + device_id = await driver.io.get_serial() + self._bucket_1_remainder = _load_vspin_calibrations(device_id) + + # -- bucket calibration -- + + @property + def bucket_1_remainder(self) -> int: + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + return self._bucket_1_remainder + + async def set_bucket_1_position_to_current(self) -> None: + """Set the current position as bucket 1 position and save calibration.""" + current_position = await self._driver.get_position() + device_id = await self._driver.io.get_serial() + remainder = await self._driver.get_home_position() - current_position + self._bucket_1_remainder = current_position % FULL_ROTATION + _save_vspin_calibrations(device_id, remainder) + + async def get_bucket_1_position(self) -> int: + """Get the bucket 1 position based on calibration.""" + if self._bucket_1_remainder is None: + raise bucket_1_not_set_error + home_position = await self._driver.get_home_position() + bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder + current_position = await self._driver.get_position() + bucket_1_position = ( + FULL_ROTATION + * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) + + bucket_1_position_mod_full_rotation + ) + return bucket_1_position + + # -- CentrifugeBackend interface -- async def open_door(self): - if await self.get_door_open(): + if await self._driver.get_door_open(): return - await self._send_command(bytes.fromhex("aa022600062e")) + await self._driver.send_command(bytes.fromhex("aa022600062e")) await asyncio.sleep(4) async def close_door(self): - if not (await self.get_door_open()): + if not (await self._driver.get_door_open()): return - await self._send_command(bytes.fromhex("aa022600042c")) + await self._driver.send_command(bytes.fromhex("aa022600042c")) await asyncio.sleep(2) async def lock_door(self): - if await self.get_door_open(): + if await self._driver.get_door_open(): raise RuntimeError("Cannot lock door while it is open.") - if await self.get_door_locked(): + if await self._driver.get_door_locked(): return - await self._send_command(bytes.fromhex("aa0226000028")) + await self._driver.send_command(bytes.fromhex("aa0226000028")) async def unlock_door(self): - if not await self.get_door_locked(): + if not await self._driver.get_door_locked(): return - await self._send_command(bytes.fromhex("aa022600042c")) + await self._driver.send_command(bytes.fromhex("aa022600042c")) async def lock_bucket(self): - if await self.get_bucket_locked(): + if await self._driver.get_bucket_locked(): return - await self._send_command(bytes.fromhex("aa022600072f")) + await self._driver.send_command(bytes.fromhex("aa022600072f")) async def unlock_bucket(self): - if not await self.get_bucket_locked(): + if not await self._driver.get_bucket_locked(): return - await self._send_command(bytes.fromhex("aa022600062e")) + await self._driver.send_command(bytes.fromhex("aa022600062e")) async def go_to_bucket1(self): await self.go_to_position(await self.get_bucket_1_position()) @@ -335,16 +360,16 @@ async def go_to_position(self, position: int): byte_string = bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") sum_byte = (sum(byte_string) - 0xAA) & 0xFF byte_string += sum_byte.to_bytes(1, byteorder="little") - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(byte_string) - - while abs(await self.get_position() - position) > 10: + await self._driver.send_command(bytes.fromhex("aa0226000028")) + await self._driver.send_command(bytes.fromhex("aa0117021a")) + await self._driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._driver.send_command(bytes.fromhex("aa0117041c")) + await self._driver.send_command(bytes.fromhex("aa01170119")) + await self._driver.send_command(bytes.fromhex("aa010b0c")) + await self._driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._driver.send_command(byte_string) + + while abs(await self._driver.get_position() - position) > 10: await asyncio.sleep(0.1) await self.open_door() @@ -370,10 +395,10 @@ async def spin( Args: g: relative centrifugal force, also known as g-force duration: time in seconds spent at speed (g) - backend_params: VSpinBackend.SpinParams with acceleration and deceleration (0-1). + backend_params: VSpinCentrifugeBackend.SpinParams with acceleration and deceleration (0-1). """ if not isinstance(backend_params, self.SpinParams): - backend_params = VSpinBackend.SpinParams() + backend_params = VSpinCentrifugeBackend.SpinParams() acceleration = backend_params.acceleration deceleration = backend_params.deceleration @@ -387,14 +412,14 @@ async def spin( if duration < 1: raise ValueError("Spin time must be at least 1 second") - if await self.get_door_open(): + if await self._driver.get_door_open(): await self.close_door() - if not await self.get_door_locked(): + if not await self._driver.get_door_locked(): await self.lock_door() - if await self.get_bucket_locked(): + if await self._driver.get_bucket_locked(): await self.unlock_bucket() - rpm = VSpinBackend.g_to_rpm(g) + rpm = VSpinCentrifugeBackend.g_to_rpm(g) acceleration_ticks_per_second2 = 12903.2 * acceleration rounds_per_second = rpm / 60 @@ -403,7 +428,7 @@ async def spin( distance_at_speed = ticks_per_second * duration - current_position = await self.get_position() + current_position = await self._driver.get_position() final_position = int(current_position + distance_during_acceleration + distance_at_speed) if final_position > 2**32 - 1: @@ -420,49 +445,52 @@ async def spin( checksum = (sum(byte_string) - 0xAA) & 0xFF byte_string += checksum.to_bytes(1, byteorder="little") - await self._send_command(bytes.fromhex("aa0226000028")) - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + await self._driver.send_command(bytes.fromhex("aa0226000028")) + await self._driver.send_command(bytes.fromhex("aa0117021a")) + await self._driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._driver.send_command(bytes.fromhex("aa0117041c")) + await self._driver.send_command(bytes.fromhex("aa01170119")) + await self._driver.send_command(bytes.fromhex("aa010b0c")) + await self._driver.send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) - await self._send_command(byte_string) + await self._driver.send_command(byte_string) - while await self.get_tachometer() < rpm * 0.95 and await self.get_position() < final_position: + while ( + await self._driver.get_tachometer() < rpm * 0.95 + and await self._driver.get_position() < final_position + ): await asyncio.sleep(0.1) - if await self.get_position() < final_position: - decel_start_position = await self.get_position() + distance_at_speed + if await self._driver.get_position() < final_position: + decel_start_position = await self._driver.get_position() + distance_at_speed - while await self.get_position() < decel_start_position: + while await self._driver.get_position() < decel_start_position: await asyncio.sleep(0.1) - await self._send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + await self._driver.send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") decel_command = bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("0000") decel_command += ((sum(decel_command) - 0xAA) & 0xFF).to_bytes(1, byteorder="little") - await self._send_command(decel_command) + await self._driver.send_command(decel_command) await asyncio.sleep(2) async def _reset_to_zero(): - await self._send_command(bytes.fromhex("aa0117021a")) - await self._send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._send_command(bytes.fromhex("aa0117041c")) - await self._send_command(bytes.fromhex("aa01170119")) - await self._send_command(bytes.fromhex("aa010b0c")) - await self._send_command(bytes.fromhex("aa010001")) - await self._send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) - await self._send_command(bytes.fromhex("aa0194b61283000012010000f3")) - await self._send_command(bytes.fromhex("aa01192842")) + await self._driver.send_command(bytes.fromhex("aa0117021a")) + await self._driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self._driver.send_command(bytes.fromhex("aa0117041c")) + await self._driver.send_command(bytes.fromhex("aa01170119")) + await self._driver.send_command(bytes.fromhex("aa010b0c")) + await self._driver.send_command(bytes.fromhex("aa010001")) + await self._driver.send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self._driver.send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self._driver.send_command(bytes.fromhex("aa01192842")) await _reset_to_zero() - start = await self.get_home_position() + start = await self._driver.get_home_position() num_tries = 0 - while await self.get_home_position() == start: + while await self._driver.get_home_position() == start: await asyncio.sleep(0.1) num_tries += 1 if num_tries % 25 == 0: @@ -471,14 +499,15 @@ async def _reset_to_zero(): raise RuntimeError("Home position did not change after spin.") -class Access2Backend(Driver): - """Backend for the Agilent Access2 centrifuge loader.""" +# --------------------------------------------------------------------------- +# Access2 Driver — pure driver, no capabilities +# --------------------------------------------------------------------------- - def __init__( - self, - device_id: str, - timeout: int = 60, - ): + +class Access2Driver(Driver): + """FTDI driver for the Agilent Access2 centrifuge loader.""" + + def __init__(self, device_id: str, timeout: int = 60): """ Args: device_id: The libftdi id for the loader. Find using @@ -581,7 +610,7 @@ async def unload(self): # --------------------------------------------------------------------------- -# Device +# Devices # --------------------------------------------------------------------------- @@ -596,7 +625,7 @@ def __init__( size_y: float = 0.0, size_z: float = 0.0, ): - backend = VSpinBackend(device_id=device_id) + driver = VSpinDriver(device_id=device_id) Resource.__init__( self, name=name, @@ -606,8 +635,8 @@ def __init__( model="Agilent VSpin", category="centrifuge", ) - Device.__init__(self, driver=backend) - self._driver: VSpinBackend = backend + Device.__init__(self, driver=driver) + self._driver: VSpinDriver = driver bucket1 = ResourceHolder( name=f"{name}_bucket1", @@ -626,7 +655,9 @@ def __init__( self.assign_child_resource(bucket1, location=Coordinate.zero()) self.assign_child_resource(bucket2, location=Coordinate.zero()) - self.centrifuging = CentrifugingCapability(backend=backend, buckets=(bucket1, bucket2)) + self.centrifuging = CentrifugingCapability( + backend=VSpinCentrifugeBackend(driver), buckets=(bucket1, bucket2) + ) self._capabilities = [self.centrifuging] def serialize(self) -> dict: @@ -645,7 +676,7 @@ def __init__( size_y: float = 0.0, size_z: float = 0.0, ): - backend = Access2Backend(device_id=device_id) + driver = Access2Driver(device_id=device_id) ResourceHolder.__init__( self, name=name, @@ -656,8 +687,8 @@ def __init__( category="loader", child_location=Coordinate.zero(), ) - Device.__init__(self, driver=backend) - self._driver: Access2Backend = backend + Device.__init__(self, driver=driver) + self._driver: Access2Driver = driver self._vspin = vspin def serialize(self) -> dict: diff --git a/pylabrobot/azenta/__init__.py b/pylabrobot/azenta/__init__.py index 8a3a4c54a02..b00149e5035 100644 --- a/pylabrobot/azenta/__init__.py +++ b/pylabrobot/azenta/__init__.py @@ -1,2 +1,2 @@ -from .a4s import A4S, A4SBackend -from .xpeel import XPeel, XPeelBackend +from .a4s import A4S, A4SDriver, A4SSealerBackend, A4SStatus, A4STemperatureBackend +from .xpeel import XPeel, XPeelDriver, XPeelPeelerBackend diff --git a/pylabrobot/azenta/a4s.py b/pylabrobot/azenta/a4s.py index a94e1fc7a7f..6417a006d65 100644 --- a/pylabrobot/azenta/a4s.py +++ b/pylabrobot/azenta/a4s.py @@ -23,13 +23,52 @@ from pylabrobot.resources.carrier import PlateHolder -class A4SBackend(SealerBackend, TemperatureControllerBackend, Driver): - """Backend for the Azenta a4S thermal sealer. +@dataclasses.dataclass +class A4SStatus: + class SystemStatus(enum.Enum): + idle = 0 + single_cycle = 1 + repeat_cycle = 2 + error = 3 + finish = 4 + + class HeaterBlockStatus(enum.Enum): + heater_off = 0 + ready = 1 + heating = 2 + cooling = 3 + converging = 4 + + @dataclasses.dataclass + class SensorStatus: + shuttle_middle_sensor: bool + shuttle_open_sensor: bool + shuttle_close_sensor: bool + clean_door_sensor: bool + seal_roll_sensor: bool + heater_motor_up_sensor: bool + heater_motor_down_sensor: bool + + current_temperature: float + system_status: SystemStatus + heater_block_status: HeaterBlockStatus + error_code: int + warning_code: int + sensor_status: SensorStatus + remaining_time: int + + +class A4SDriver(Driver): + """Serial driver for the Azenta a4S thermal sealer. + + Owns I/O, connection lifecycle, and device-level operations (status polling, + system reset, heater on/off, timing). https://web.azenta.com/hubfs/azenta-files/resources/tech-drawings/TD-automated-roll-heat-sealer.pdf """ def __init__(self, port: str, timeout: int = 20) -> None: + super().__init__() if not HAS_SERIAL: raise RuntimeError( "pyserial is not installed. Install with: pip install pylabrobot[serial]. " @@ -47,13 +86,13 @@ def __init__(self, port: str, timeout: int = 20) -> None: ) async def setup(self): - await Driver.setup(self) + await super().setup() await self.io.setup() await self.system_reset() async def stop(self): await self.set_heater(on=False) - await Driver.stop(self) + await super().stop() await self.io.stop() # -- serial protocol -- @@ -62,7 +101,7 @@ async def send_command(self, command: str): await self.io.write(command.encode()) await asyncio.sleep(0.1) - async def _read_message(self) -> str: + async def read_message(self) -> str: start = time.time() r, x = b"", b"" has_read_r = False @@ -81,43 +120,9 @@ async def _read_message(self) -> str: # -- status -- - @dataclasses.dataclass - class Status: - class SystemStatus(enum.Enum): - idle = 0 - single_cycle = 1 - repeat_cycle = 2 - error = 3 - finish = 4 - - class HeaterBlockStatus(enum.Enum): - heater_off = 0 - ready = 1 - heating = 2 - cooling = 3 - converging = 4 - - @dataclasses.dataclass - class SensorStatus: - shuttle_middle_sensor: bool - shuttle_open_sensor: bool - shuttle_close_sensor: bool - clean_door_sensor: bool - seal_roll_sensor: bool - heater_motor_up_sensor: bool - heater_motor_down_sensor: bool - - current_temperature: float - system_status: SystemStatus - heater_block_status: HeaterBlockStatus - error_code: int - warning_code: int - sensor_status: SensorStatus - remaining_time: int - - async def get_status(self) -> Status: + async def get_status(self) -> A4SStatus: while True: - message = await self._read_message() + message = await self.read_message() if message[1] == "T": break @@ -130,13 +135,13 @@ async def get_status(self) -> Status: sensor_status = int(str(parameters[5])) - return A4SBackend.Status( + return A4SStatus( current_temperature=int(str(parameters[0])) / 10, - system_status=A4SBackend.Status.SystemStatus(int(str(parameters[1]))), - heater_block_status=A4SBackend.Status.HeaterBlockStatus(int(str(parameters[2]))), + system_status=A4SStatus.SystemStatus(int(str(parameters[1]))), + heater_block_status=A4SStatus.HeaterBlockStatus(int(str(parameters[2]))), error_code=error_code, warning_code=int(str(parameters[4])), - sensor_status=A4SBackend.Status.SensorStatus( + sensor_status=A4SStatus.SensorStatus( shuttle_middle_sensor=sensor_status & 0x0001 != 0, shuttle_open_sensor=sensor_status & 0x0002 != 0, shuttle_close_sensor=sensor_status & 0x0004 != 0, @@ -148,12 +153,12 @@ async def get_status(self) -> Status: remaining_time=int(str(parameters[6])), ) - async def _wait_for_status(self, statuses: Set["A4SBackend.Status.SystemStatus"]) -> Status: + async def wait_for_status(self, statuses: Set[A4SStatus.SystemStatus]) -> A4SStatus: start = time.time() while True: status = await self.get_status() - if status.system_status == A4SBackend.Status.SystemStatus.error: + if status.system_status == A4SStatus.SystemStatus.error: raise RuntimeError(f"An error occurred: {status.error_code}") if status.system_status in statuses: @@ -164,9 +169,9 @@ async def _wait_for_status(self, statuses: Set["A4SBackend.Status.SystemStatus"] await asyncio.sleep(0.01) - async def _wait_for_shuttle_open_sensor( + async def wait_for_shuttle_open_sensor( self, shuttle_open: bool, timeout: float = 30.0 - ) -> Status: + ) -> A4SStatus: start = time.time() while True: status = await self.get_status() @@ -177,12 +182,12 @@ async def _wait_for_shuttle_open_sensor( async def system_reset(self): await self.send_command("*00SR=zz!") - return await self._wait_for_status({A4SBackend.Status.SystemStatus.idle}) + return await self.wait_for_status({A4SStatus.SystemStatus.idle}) async def set_heater(self, on: bool): command = "*00H1ZZ" if on else "*00H0ZZ" await self.send_command(command) - return await self._wait_for_status({A4SBackend.Status.SystemStatus.idle}) + return await self.wait_for_status({A4SStatus.SystemStatus.idle}) async def set_time(self, seconds: float): deciseconds = seconds * 10 @@ -195,26 +200,50 @@ async def get_remaining_time(self) -> int: status = await self.get_status() return status.remaining_time - # -- SealerBackend -- + def serialize(self) -> dict: + return {**super().serialize(), "port": self.port, "timeout": self.timeout} + + +class A4SSealerBackend(SealerBackend): + """Translates SealerBackend operations into A4S driver commands.""" + + def __init__(self, driver: A4SDriver): + self._driver = driver async def seal(self, temperature: int, duration: float): - await self.set_temperature(temperature) - await self.set_time(duration) - await self.send_command("*00GS=zz!") - await self._wait_for_status({A4SBackend.Status.SystemStatus.single_cycle}) - return await self._wait_for_status( - {A4SBackend.Status.SystemStatus.idle, A4SBackend.Status.SystemStatus.finish} + await self._driver.send_command(f"*00DH={round(temperature):04d}zz!") + await self._wait_for_temperature(temperature, timeout=300) + await self._driver.set_time(duration) + await self._driver.send_command("*00GS=zz!") + await self._driver.wait_for_status({A4SStatus.SystemStatus.single_cycle}) + return await self._driver.wait_for_status( + {A4SStatus.SystemStatus.idle, A4SStatus.SystemStatus.finish} ) - async def open(self) -> Status: - await self.send_command("*00MO=zz!") - return await self._wait_for_shuttle_open_sensor(True) + async def open(self): + await self._driver.send_command("*00MO=zz!") + return await self._driver.wait_for_shuttle_open_sensor(True) - async def close(self) -> Status: - await self.send_command("*00MC=zz!") - return await self._wait_for_shuttle_open_sensor(False) + async def close(self): + await self._driver.send_command("*00MC=zz!") + return await self._driver.wait_for_shuttle_open_sensor(False) + + async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: float = 0.5): + start = time.time() + while True: + status = await self._driver.get_status() + if abs(status.current_temperature - degrees) < tolerance: + break + if time.time() - start > timeout: + raise TimeoutError("Timeout while waiting for temperature") + await asyncio.sleep(0.1) - # -- TemperatureControllerBackend -- + +class A4STemperatureBackend(TemperatureControllerBackend): + """Translates TemperatureControllerBackend operations into A4S driver commands.""" + + def __init__(self, driver: A4SDriver): + self._driver = driver @property def supports_active_cooling(self) -> bool: @@ -224,7 +253,7 @@ async def set_temperature(self, temperature: float): if not (50 <= temperature <= 200): raise ValueError("Temperature out of range. Please enter a value between 50 and 200.") command = f"*00DH={round(temperature):04d}zz!" - await self.send_command(command) + await self._driver.send_command(command) await self._wait_for_temperature(temperature, timeout=300) async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: float = 0.5): @@ -238,11 +267,11 @@ async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: await asyncio.sleep(0.1) async def get_current_temperature(self) -> float: - status = await self.get_status() + status = await self._driver.get_status() return status.current_temperature async def deactivate(self): - await self.set_heater(on=False) + await self._driver.set_heater(on=False) class A4S(PlateHolder, Device): @@ -254,7 +283,8 @@ class A4S(PlateHolder, Device): def __init__( self, name: str, - driver: A4SBackend, + port: str, + timeout: int = 20, size_x: float = 222, size_y: float = 500, size_z: float = 276, @@ -264,6 +294,7 @@ def __init__( model: Optional[str] = None, ): raise NotImplementedError("A4S is missing resource definition.") + driver = A4SDriver(port=port, timeout=timeout) PlateHolder.__init__( self, name=name, @@ -276,9 +307,9 @@ def __init__( model=model, ) Device.__init__(self, driver=driver) - self._driver: A4SBackend = driver - self.sealer = SealingCapability(backend=driver) - self.tc = TemperatureControlCapability(backend=driver) + self._driver: A4SDriver = driver + self.sealer = SealingCapability(backend=A4SSealerBackend(driver)) + self.tc = TemperatureControlCapability(backend=A4STemperatureBackend(driver)) self._capabilities = [self.tc, self.sealer] def serialize(self) -> dict: diff --git a/pylabrobot/azenta/xpeel.py b/pylabrobot/azenta/xpeel.py index c3dfe8d636c..79eafeeedcb 100644 --- a/pylabrobot/azenta/xpeel.py +++ b/pylabrobot/azenta/xpeel.py @@ -18,8 +18,12 @@ from pylabrobot.serializer import SerializableMixin -class XPeelBackend(PeelerBackend, Driver): - """Backend for the Azenta XPeel automated plate seal remover (RS-232).""" +class XPeelDriver(Driver): + """Serial driver for the Azenta XPeel automated plate seal remover (RS-232). + + Owns the hardware connection and provides generic send/receive plus device-level operations + (status, reset, conveyor/elevator movement, tape, seal sensor). + """ BAUDRATE = 9600 RESPONSE_TIMEOUT = 20.0 @@ -53,6 +57,7 @@ def __init__(self, port: str, timeout: Optional[float] = None): "pyserial is not installed. Install with: pip install pylabrobot[serial]. " f"Import error: {_SERIAL_IMPORT_ERROR}" ) + super().__init__() self.logger = logging.getLogger(__name__) self.port = port self.response_timeout = timeout if timeout is not None else self.RESPONSE_TIMEOUT @@ -93,7 +98,7 @@ def parse_ready_line(cls, line: str): except Exception: return None - async def _send_command( + async def send_command( self, cmd, expect_ack=False, wait_for_ready=False, clear_buffer=True ) -> List[str]: full_cmd = cmd if cmd.endswith("\r\n") else f"{cmd}\r\n" @@ -143,61 +148,20 @@ async def _send_command( async def get_status(self) -> Tuple[int, int, int]: """Request instrument status; returns three error codes.""" - resp = await self._send_command("*stat") + resp = await self.send_command("*stat") return tuple([int(x) for x in resp[-1].split(":")[1].split(",")]) # type: ignore async def get_version(self): """Request firmware version.""" - return await self._send_command("*version") + return await self.send_command("*version") async def reset(self): """Request reset.""" - return await self._send_command("*reset", expect_ack=True, wait_for_ready=True) - - async def restart(self, backend_params: Optional[SerializableMixin] = None): - """Request restart with full homing sequence.""" - return await self._send_command("*restart", expect_ack=True, wait_for_ready=True) - - @dataclass - class PeelParams(BackendParams): - begin_location: Literal[-2, 0, 2, 4] = 0 - fast: bool = False - adhere_time: float = 2.5 - - async def peel( - self, - backend_params: Optional[SerializableMixin] = None, - ): - """Run an automated de-seal cycle.""" - if not isinstance(backend_params, self.PeelParams): - backend_params = XPeelBackend.PeelParams() - - adhere_time = backend_params.adhere_time - begin_location = backend_params.begin_location - fast = backend_params.fast - - if adhere_time not in {2.5, 5.0, 7.5, 10.0}: - raise ValueError("adhere_time must be one of: 2.5, 5.0, 7.5, 10.0") - if begin_location not in {-2, 0, 2, 4}: - raise ValueError("begin_location must be one of: -2, 0, 2, 4") - - parameter_set = { - (-2, True): 1, - (-2, False): 2, - (0, True): 3, - (0, False): 4, - (2, True): 5, - (2, False): 6, - (4, True): 7, - (4, False): 8, - }.get((begin_location, fast), 9) - - cmd = f"*xpeel:{parameter_set}{adhere_time}" - return await self._send_command(cmd, expect_ack=True, wait_for_ready=True) + return await self.send_command("*reset", expect_ack=True, wait_for_ready=True) async def seal_check(self) -> Literal["seal_detected", "no_seal", "plate_not_detected"]: """Check for seal presence.""" - resp = await self._send_command("*sealcheck", expect_ack=True, wait_for_ready=True) + resp = await self.send_command("*sealcheck", expect_ack=True, wait_for_ready=True) ready_line = resp[-1] parsed = self.parse_ready_line(ready_line) if parsed is None: @@ -215,7 +179,7 @@ async def seal_check(self) -> Literal["seal_detected", "no_seal", "plate_not_det async def get_tape_remaining(self): """Query remaining tape. Returns (supply_remaining, takeup_remaining) in number of deseals.""" - resp = await self._send_command("*tapeleft", expect_ack=True, wait_for_ready=True) + resp = await self.send_command("*tapeleft", expect_ack=True, wait_for_ready=True) tape_line = resp[-1] parts = tape_line.split(":")[1].split(",") supply_remaining = int(parts[0]) * 10 @@ -225,53 +189,102 @@ async def get_tape_remaining(self): async def enable_plate_check(self, enabled=True): """Enable or disable plate presence check.""" flag = "y" if enabled else "n" - return await self._send_command(f"*platecheck:{flag}", expect_ack=True, wait_for_ready=True) + return await self.send_command(f"*platecheck:{flag}", expect_ack=True, wait_for_ready=True) async def get_seal_sensor_status(self): """Get seal sensor threshold value (0-999).""" - return await self._send_command("*sealstat", expect_ack=True, wait_for_ready=True) + return await self.send_command("*sealstat", expect_ack=True, wait_for_ready=True) async def set_seal_threshold_upper(self, value: int): """Set the upper seal detected threshold (0-999).""" if not 0 <= value <= 999: raise ValueError("value must be between 0 and 999") - return await self._send_command( - f"*sealhigher:{value:03d}", expect_ack=True, wait_for_ready=True - ) + return await self.send_command(f"*sealhigher:{value:03d}", expect_ack=True, wait_for_ready=True) async def set_seal_threshold_lower(self, value: int): """Set the lower seal detected threshold (0-999).""" if not 0 <= value <= 999: raise ValueError("value must be between 0 and 999") - return await self._send_command(f"*seallower:{value:03d}", expect_ack=True, wait_for_ready=True) + return await self.send_command(f"*seallower:{value:03d}", expect_ack=True, wait_for_ready=True) async def move_conveyor_out(self): """Move conveyor out.""" - return await self._send_command("*moveout", expect_ack=True, wait_for_ready=True) + return await self.send_command("*moveout", expect_ack=True, wait_for_ready=True) async def move_conveyor_in(self): """Move conveyor in.""" - return await self._send_command("*movein", expect_ack=True, wait_for_ready=True) + return await self.send_command("*movein", expect_ack=True, wait_for_ready=True) async def move_elevator_down(self): """Move elevator down.""" - return await self._send_command("*movedown", expect_ack=True, wait_for_ready=True) + return await self.send_command("*movedown", expect_ack=True, wait_for_ready=True) async def move_elevator_up(self): """Move elevator up.""" - return await self._send_command("*moveup", expect_ack=True, wait_for_ready=True) + return await self.send_command("*moveup", expect_ack=True, wait_for_ready=True) async def advance_tape(self): """Advance tape / move spool.""" - return await self._send_command("*movespool", expect_ack=True, wait_for_ready=True) + return await self.send_command("*movespool", expect_ack=True, wait_for_ready=True) + + +class XPeelPeelerBackend(PeelerBackend): + """Translates PeelerBackend interface into XPeel driver commands. + + Protocol encoding for peel and restart operations lives here. + """ + + @dataclass + class PeelParams(BackendParams): + begin_location: Literal[-2, 0, 2, 4] = 0 + fast: bool = False + adhere_time: float = 2.5 + + def __init__(self, driver: XPeelDriver): + self._driver = driver + + async def peel( + self, + backend_params: Optional[SerializableMixin] = None, + ): + """Run an automated de-seal cycle.""" + if not isinstance(backend_params, self.PeelParams): + backend_params = XPeelPeelerBackend.PeelParams() + + adhere_time = backend_params.adhere_time + begin_location = backend_params.begin_location + fast = backend_params.fast + + if adhere_time not in {2.5, 5.0, 7.5, 10.0}: + raise ValueError("adhere_time must be one of: 2.5, 5.0, 7.5, 10.0") + if begin_location not in {-2, 0, 2, 4}: + raise ValueError("begin_location must be one of: -2, 0, 2, 4") + + parameter_set = { + (-2, True): 1, + (-2, False): 2, + (0, True): 3, + (0, False): 4, + (2, True): 5, + (2, False): 6, + (4, True): 7, + (4, False): 8, + }.get((begin_location, fast), 9) + + cmd = f"*xpeel:{parameter_set}{adhere_time}" + return await self._driver.send_command(cmd, expect_ack=True, wait_for_ready=True) + + async def restart(self, backend_params: Optional[SerializableMixin] = None): + """Request restart with full homing sequence.""" + return await self._driver.send_command("*restart", expect_ack=True, wait_for_ready=True) class XPeel(Device): """Azenta XPeel automated plate seal remover.""" def __init__(self, name: str, port: str, timeout: Optional[float] = None): - backend = XPeelBackend(port=port, timeout=timeout) - super().__init__(driver=backend) - self._driver: XPeelBackend = backend - self.peeler = PeelingCapability(backend=backend) + driver = XPeelDriver(port=port, timeout=timeout) + super().__init__(driver=driver) + self._driver: XPeelDriver = driver + self.peeler = PeelingCapability(backend=XPeelPeelerBackend(driver)) self._capabilities = [self.peeler] diff --git a/pylabrobot/bmg_labtech/__init__.py b/pylabrobot/bmg_labtech/__init__.py index c24a8cd4761..3a5a13983a0 100644 --- a/pylabrobot/bmg_labtech/__init__.py +++ b/pylabrobot/bmg_labtech/__init__.py @@ -1 +1,8 @@ -from .clariostar import CLARIOstar, CLARIOstarBackend +from .clariostar import ( + CLARIOstar, + CLARIOstarAbsorbanceBackend, + CLARIOstarAbsorbanceParams, + CLARIOstarDriver, + CLARIOstarFluorescenceBackend, + CLARIOstarLuminescenceBackend, +) diff --git a/pylabrobot/bmg_labtech/clariostar.py b/pylabrobot/bmg_labtech/clariostar.py deleted file mode 100644 index 9d0dc6e3630..00000000000 --- a/pylabrobot/bmg_labtech/clariostar.py +++ /dev/null @@ -1,434 +0,0 @@ -import asyncio -import logging -import math -import struct -import sys -import time -from dataclasses import dataclass -from typing import List, Optional, Union - -from pylabrobot.capabilities.plate_reading.absorbance import ( - AbsorbanceBackend, - AbsorbanceCapability, - AbsorbanceResult, -) -from pylabrobot.capabilities.plate_reading.fluorescence import ( - FluorescenceBackend, - FluorescenceCapability, - FluorescenceResult, -) -from pylabrobot.capabilities.plate_reading.luminescence import ( - LuminescenceBackend, - LuminescenceCapability, - LuminescenceResult, -) -from pylabrobot.device import Device, Driver -from pylabrobot.io.ftdi import FTDI -from pylabrobot.resources import Coordinate, PlateHolder, Resource -from pylabrobot.resources.plate import Plate -from pylabrobot.resources.well import Well -from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.serializer import SerializableMixin -from pylabrobot.utils.list import reshape_2d - -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal - -logger = logging.getLogger("pylabrobot") - - -# --------------------------------------------------------------------------- -# Backend -# --------------------------------------------------------------------------- - - -class CLARIOstarBackend(AbsorbanceBackend, LuminescenceBackend, FluorescenceBackend, Driver): - """Backend for the BMG Labtech CLARIOstar plate reader. - - Communicates over FTDI USB (VID 0x0403, PID 0xBB68) at 125000 baud. - Supports absorbance and luminescence. Fluorescence is not yet implemented. - """ - - def __init__(self, device_id: Optional[str] = None): - super().__init__() - self.io = FTDI( - human_readable_device_name="BMG CLARIOstar", device_id=device_id, vid=0x0403, pid=0xBB68 - ) - - async def setup(self) -> None: - await self.io.setup() - await self.io.set_baudrate(125000) - await self.io.set_line_property(8, 0, 0) # 8N1 - await self.io.set_latency_timer(2) - - await self._initialize() - await self._request_eeprom_data() - - async def stop(self) -> None: - await self.io.stop() - - # -- Low-level protocol --------------------------------------------------- - - async def read_resp(self, timeout: float = 20) -> bytes: - """Read a response terminated by 0x0D.""" - d = b"" - last_read = b"" - end_byte_found = False - t = time.time() - - while True: - last_read = await self.io.read(25) - if len(last_read) > 0: - d += last_read - end_byte_found = d[-1] == 0x0D - if len(last_read) < 25 and end_byte_found: - break - else: - if end_byte_found: - break - if time.time() - t > timeout: - logger.warning("timed out reading response") - break - await asyncio.sleep(0.0001) - - logger.debug("read %s", d.hex()) - return d - - async def send(self, cmd: Union[bytearray, bytes], read_timeout: float = 20) -> bytes: - """Send a command with 16-bit checksum + 0x0D terminator and return the response.""" - checksum = (sum(cmd) & 0xFFFF).to_bytes(2, byteorder="big") - cmd = cmd + checksum + b"\x0d" - logger.debug("sending %s", cmd.hex()) - w = await self.io.write(cmd) - logger.debug("wrote %s bytes", w) - assert w == len(cmd) - return await self.read_resp(timeout=read_timeout) - - async def _wait_for_ready_and_return(self, ret: bytes, timeout: float = 150) -> bytes: - """Poll command status until the device reports ready.""" - last_status = None - t = time.time() - while time.time() - t < timeout: - await asyncio.sleep(0.1) - command_status = await self._read_command_status() - - if len(command_status) != 24: - logger.warning( - "unexpected response %s. Expected 24 bytes for command status.", command_status - ) - continue - - if command_status != last_status: - logger.info("status changed %s", command_status.hex()) - last_status = command_status - else: - continue - - if command_status[2] != 0x18 or command_status[3] != 0x0C or command_status[4] != 0x01: - logger.warning("unexpected response header %s", command_status) - - if command_status[5] not in {0x25, 0x05}: - logger.warning("unexpected status byte %s", command_status) - - if command_status[5] == 0x05: - logger.debug("status is ready") - return ret - - raise TimeoutError("CLARIOstar did not become ready within timeout.") - - async def _read_command_status(self) -> bytes: - return await self.send(b"\x02\x00\x09\x0c\x80\x00") - - async def _initialize(self) -> None: - command_response = await self.send(b"\x02\x00\x0d\x0c\x01\x00\x00\x10\x02\x00") - await self._wait_for_ready_and_return(command_response) - - async def _request_eeprom_data(self) -> None: - eeprom_response = await self.send(b"\x02\x00\x0f\x0c\x05\x07\x00\x00\x00\x00\x00\x00") - await self._wait_for_ready_and_return(eeprom_response) - - # -- Tray control --------------------------------------------------------- - - async def open(self) -> None: - """Open the plate tray.""" - open_response = await self.send(b"\x02\x00\x0e\x0c\x03\x01\x00\x00\x00\x00\x00") - await self._wait_for_ready_and_return(open_response) - - async def close(self) -> None: - """Close the plate tray.""" - close_response = await self.send(b"\x02\x00\x0e\x0c\x03\x00\x00\x00\x00\x00\x00") - await self._wait_for_ready_and_return(close_response) - - # -- Helpers -------------------------------------------------------------- - - async def _mp_and_focus_height_value(self) -> None: - resp = await self.send(b"\x02\x00\x0f\x0c\x05\17\x00\x00\x00\x00\x00\x00") - await self._wait_for_ready_and_return(resp) - - async def _read_order_values(self) -> bytes: - return await self.send(b"\x02\x00\x0f\x0c\x05\x1d\x00\x00\x00\x00\x00\x00") - - async def _status_hw(self) -> bytes: - resp = await self.send(b"\x02\x00\x09\x0c\x81\x00") - return await self._wait_for_ready_and_return(resp) - - async def _get_measurement_values(self) -> bytes: - return await self.send(b"\x02\x00\x0f\x0c\x05\x02\x00\x00\x00\x00\x00\x00") - - def _plate_bytes(self, plate: Plate) -> bytes: - """Encode plate geometry into the 62-byte binary format.""" - - def float_to_bytes(f: float) -> bytes: - return round(f * 100).to_bytes(2, byteorder="big") - - plate_length = plate.get_absolute_size_x() - plate_width = plate.get_absolute_size_y() - - well_0 = plate.get_well(0) - assert well_0.location is not None, "Well 0 must be assigned to a plate" - plate_x1 = well_0.location.x + well_0.center().x - plate_y1 = plate_width - (well_0.location.y + well_0.center().y) - plate_xn = plate_length - plate_x1 - plate_yn = plate_width - plate_y1 - - plate_cols = plate.num_items_x - plate_rows = plate.num_items_y - - wells = ([1] * plate.num_items) + ([0] * (384 - plate.num_items)) - well_mask: int = sum(b << i for i, b in enumerate(wells[::-1])) - wells_bytes = well_mask.to_bytes(48, "big") - - return ( - float_to_bytes(plate_length) - + float_to_bytes(plate_width) - + float_to_bytes(plate_x1) - + float_to_bytes(plate_y1) - + float_to_bytes(plate_xn) - + float_to_bytes(plate_yn) - + plate_cols.to_bytes(1, byteorder="big") - + plate_rows.to_bytes(1, byteorder="big") - + wells_bytes - ) - - # -- Measurement runs ----------------------------------------------------- - - async def _run_luminescence(self, focal_height: float, plate: Plate) -> bytes: - assert 0 <= focal_height <= 25, "focal height must be between 0 and 25 mm" - focal_height_data = int(focal_height * 100).to_bytes(2, byteorder="big") - plate_bytes = self._plate_bytes(plate) - - payload = ( - b"\x04" + plate_bytes + b"\x02\x01\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27" - b"\x0f\x27\x0f\x01" + focal_height_data + b"\x00\x00\x01\x00\x00\x0e\x10\x00\x01\x00\x01" - b"\x00\x01\x00\x01\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00" - b"\x00\x01\x00\x00\x00\x01\x00\x64\x00\x20\x00\x00" - ) - message_size = (len(payload) + 7).to_bytes(2, byteorder="big") - cmd = b"\x02" + message_size + b"\x0c" + payload - run_response = await self.send(cmd) - - last_status = None - while True: - await asyncio.sleep(0.1) - command_status = await self._read_command_status() - if command_status != last_status: - last_status = command_status - logger.info("status changed %s", command_status) - continue - if command_status == bytes( - b"\x02\x00\x18\x0c\x01\x25\x04\x2e\x00\x00\x04\x01\x00\x00\x03\x00" - b"\x00\x00\x00\xc0\x00\x01\x46\x0d" - ): - return run_response - - async def _run_absorbance(self, wavelength: float, plate: Plate) -> bytes: - wavelength_data = int(wavelength * 10).to_bytes(2, byteorder="big") - plate_bytes = self._plate_bytes(plate) - - payload = ( - b"\x04" + plate_bytes + b"\x82\x02\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27\x0f\x27" - b"\x0f\x19\x01" + wavelength_data + b"\x00\x00\x00\x64\x00\x00\x00\x00\x00\x00\x00\x64\x00" - b"\x00\x00\x00\x00\x02\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x16\x00\x01\x00\x00" - ) - message_size = (len(payload) + 7).to_bytes(2, byteorder="big") - cmd = b"\x02" + message_size + b"\x0c" + payload - run_response = await self.send(cmd) - - last_status = None - while True: - await asyncio.sleep(0.1) - command_status = await self._read_command_status() - if command_status != last_status: - last_status = command_status - logger.info("status changed %s", command_status) - continue - if command_status == bytes( - b"\x02\x00\x18\x0c\x01\x25\x04\x2e\x00\x00\x04\x01\x00\x00\x03\x00" - b"\x00\x00\x00\xc0\x00\x01\x46\x0d" - ): - return run_response - - # -- Capability methods --------------------------------------------------- - - async def read_luminescence( - self, - plate: Plate, - wells: List[Well], - focal_height: float = 13, - backend_params: Optional[SerializableMixin] = None, - ) -> List[LuminescenceResult]: - if wells != plate.get_all_items(): - raise NotImplementedError("Only full plate reads are supported for now.") - - await self._mp_and_focus_height_value() - await self._run_luminescence(focal_height=focal_height, plate=plate) - await self._read_order_values() - await self._status_hw() - - vals = await self._get_measurement_values() - num_wells = plate.num_items - start_idx = vals.index(b"\x00\x00\x00\x00\x00\x00") + len(b"\x00\x00\x00\x00\x00\x00") - data = list(vals)[start_idx : start_idx + num_wells * 4] - int_bytes = [data[i : i + 4] for i in range(0, len(data), 4)] - ints = [struct.unpack(">i", bytes(int_data))[0] for int_data in int_bytes] - floats: List[List[Optional[float]]] = reshape_2d( - [float(i) for i in ints], (plate.num_items_y, plate.num_items_x) - ) - - return [ - LuminescenceResult( - data=floats, - temperature=None, - timestamp=time.time(), - ) - ] - - @dataclass - class AbsorbanceParams(BackendParams): - report: Literal["OD", "transmittance"] = "OD" - - async def read_absorbance( - self, - plate: Plate, - wells: List[Well], - wavelength: int, - backend_params: Optional[SerializableMixin] = None, - ) -> List[AbsorbanceResult]: - if not isinstance(backend_params, self.AbsorbanceParams): - backend_params = CLARIOstarBackend.AbsorbanceParams() - - if wells != plate.get_all_items(): - raise NotImplementedError("Only full plate reads are supported for now.") - - await self._mp_and_focus_height_value() - await self._run_absorbance(wavelength=wavelength, plate=plate) - await self._read_order_values() - await self._status_hw() - - vals = await self._get_measurement_values() - num_wells = plate.num_items - div = b"\x00" * 6 - start_idx = vals.index(div) + len(div) - chromatic_data = vals[start_idx : start_idx + num_wells * 4] - ref_data = vals[start_idx + num_wells * 4 : start_idx + (num_wells * 2) * 4] - chromatic_bytes = [bytes(chromatic_data[i : i + 4]) for i in range(0, len(chromatic_data), 4)] - ref_bytes = [bytes(ref_data[i : i + 4]) for i in range(0, len(ref_data), 4)] - chromatic_reading = [struct.unpack(">i", x)[0] for x in chromatic_bytes] - reference_reading = [struct.unpack(">i", x)[0] for x in ref_bytes] - - after_values_idx = start_idx + (num_wells * 2) * 4 - c100, c0, r100, r0 = struct.unpack(">iiii", vals[after_values_idx : after_values_idx + 4 * 4]) - - real_chromatic_reading = [(cr - c0) / c100 for cr in chromatic_reading] - real_reference_reading = [(rr - r0) / r100 for rr in reference_reading] - - transmittance: List[Optional[float]] = [ - rcr / rrr * 100 for rcr, rrr in zip(real_chromatic_reading, real_reference_reading) - ] - - data: List[List[Optional[float]]] - if backend_params.report == "OD": - od: List[Optional[float]] = [ - math.log10(100 / t) if t is not None and t > 0 else None for t in transmittance - ] - data = reshape_2d(od, (plate.num_items_y, plate.num_items_x)) - elif backend_params.report == "transmittance": - data = reshape_2d(transmittance, (plate.num_items_y, plate.num_items_x)) - else: - raise ValueError(f"Invalid report type: {backend_params.report}") - - return [ - AbsorbanceResult( - data=data, - wavelength=wavelength, - temperature=None, - timestamp=time.time(), - ) - ] - - async def read_fluorescence( - self, - plate: Plate, - wells: List[Well], - excitation_wavelength: int, - emission_wavelength: int, - focal_height: float, - backend_params: Optional[SerializableMixin] = None, - ) -> List[FluorescenceResult]: - raise NotImplementedError("CLARIOstar fluorescence reading is not implemented yet.") - - -# --------------------------------------------------------------------------- -# Device -# --------------------------------------------------------------------------- - - -class CLARIOstar(Resource, Device): - """BMG Labtech CLARIOstar plate reader.""" - - def __init__( - self, - name: str, - device_id: Optional[str] = None, - size_x: float = 0.0, # TODO: measure - size_y: float = 0.0, # TODO: measure - size_z: float = 0.0, # TODO: measure - ): - backend = CLARIOstarBackend(device_id=device_id) - Resource.__init__( - self, - name=name, - size_x=size_x, - size_y=size_y, - size_z=size_z, - model="BMG CLARIOstar", - ) - Device.__init__(self, driver=backend) - self._driver: CLARIOstarBackend = backend - self.absorbance = AbsorbanceCapability(backend=backend) - self.luminescence = LuminescenceCapability(backend=backend) - self.fluorescence = FluorescenceCapability(backend=backend) - self._capabilities = [self.absorbance, self.luminescence, self.fluorescence] - - self.plate_holder = PlateHolder( - name=name + "_plate_holder", - size_x=127.76, # TODO: measure - size_y=85.48, # TODO: measure - size_z=0, - pedestal_size_z=0, - child_location=Coordinate.zero(), # TODO: measure - ) - self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) - - def serialize(self) -> dict: - return {**Resource.serialize(self), **Device.serialize(self)} - - async def open(self) -> None: - """Open the plate tray.""" - await self._driver.open() - - async def close(self) -> None: - """Close the plate tray.""" - await self._driver.close() diff --git a/pylabrobot/bmg_labtech/clariostar/__init__.py b/pylabrobot/bmg_labtech/clariostar/__init__.py new file mode 100644 index 00000000000..53da11172e5 --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/__init__.py @@ -0,0 +1,5 @@ +from .absorbance_backend import CLARIOstarAbsorbanceBackend, CLARIOstarAbsorbanceParams +from .clariostar import CLARIOstar +from .driver import CLARIOstarDriver +from .fluorescence_backend import CLARIOstarFluorescenceBackend +from .luminescence_backend import CLARIOstarLuminescenceBackend diff --git a/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py b/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py new file mode 100644 index 00000000000..dd3b926c0a6 --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py @@ -0,0 +1,104 @@ +import math +import struct +import sys +import time +from dataclasses import dataclass +from typing import List, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceBackend, AbsorbanceResult +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin +from pylabrobot.utils.list import reshape_2d + +from .driver import CLARIOstarDriver + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + + +@dataclass +class CLARIOstarAbsorbanceParams(BackendParams): + report: Literal["OD", "transmittance"] = "OD" + + +class CLARIOstarAbsorbanceBackend(AbsorbanceBackend): + """Translates AbsorbanceBackend interface into CLARIOstar driver commands.""" + + def __init__(self, driver: CLARIOstarDriver): + self._driver = driver + + # Keep the nested class for backward compat with the legacy wrapper that references + # ``CLARIOstarBackend.AbsorbanceParams``. The canonical name is now + # ``CLARIOstarAbsorbanceParams`` (module-level). + AbsorbanceParams = CLARIOstarAbsorbanceParams + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, + ) -> List[AbsorbanceResult]: + if not isinstance(backend_params, CLARIOstarAbsorbanceParams): + backend_params = CLARIOstarAbsorbanceParams() + + if wells != plate.get_all_items(): + raise NotImplementedError("Only full plate reads are supported for now.") + + await self._driver.mp_and_focus_height_value() + + wavelength_data = int(wavelength * 10).to_bytes(2, byteorder="big") + plate_bytes = self._driver.plate_bytes(plate) + payload = ( + b"\x04" + plate_bytes + b"\x82\x02\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27\x0f\x27" + b"\x0f\x19\x01" + wavelength_data + b"\x00\x00\x00\x64\x00\x00\x00\x00\x00\x00\x00\x64\x00" + b"\x00\x00\x00\x00\x02\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x16\x00\x01\x00\x00" + ) + await self._driver.run_measurement(payload) + await self._driver.read_order_values() + await self._driver.status_hw() + + vals = await self._driver.get_measurement_values() + num_wells = plate.num_items + div = b"\x00" * 6 + start_idx = vals.index(div) + len(div) + chromatic_data = vals[start_idx : start_idx + num_wells * 4] + ref_data = vals[start_idx + num_wells * 4 : start_idx + (num_wells * 2) * 4] + chromatic_bytes = [bytes(chromatic_data[i : i + 4]) for i in range(0, len(chromatic_data), 4)] + ref_bytes = [bytes(ref_data[i : i + 4]) for i in range(0, len(ref_data), 4)] + chromatic_reading = [struct.unpack(">i", x)[0] for x in chromatic_bytes] + reference_reading = [struct.unpack(">i", x)[0] for x in ref_bytes] + + after_values_idx = start_idx + (num_wells * 2) * 4 + c100, c0, r100, r0 = struct.unpack(">iiii", vals[after_values_idx : after_values_idx + 4 * 4]) + + real_chromatic_reading = [(cr - c0) / c100 for cr in chromatic_reading] + real_reference_reading = [(rr - r0) / r100 for rr in reference_reading] + + transmittance: List[Optional[float]] = [ + rcr / rrr * 100 for rcr, rrr in zip(real_chromatic_reading, real_reference_reading) + ] + + data: List[List[Optional[float]]] + if backend_params.report == "OD": + od: List[Optional[float]] = [ + math.log10(100 / t) if t is not None and t > 0 else None for t in transmittance + ] + data = reshape_2d(od, (plate.num_items_y, plate.num_items_x)) + elif backend_params.report == "transmittance": + data = reshape_2d(transmittance, (plate.num_items_y, plate.num_items_x)) + else: + raise ValueError(f"Invalid report type: {backend_params.report}") + + return [ + AbsorbanceResult( + data=data, + wavelength=wavelength, + temperature=None, + timestamp=time.time(), + ) + ] diff --git a/pylabrobot/bmg_labtech/clariostar/clariostar.py b/pylabrobot/bmg_labtech/clariostar/clariostar.py new file mode 100644 index 00000000000..1c11ea58757 --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/clariostar.py @@ -0,0 +1,61 @@ +from typing import Optional + +from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability +from pylabrobot.capabilities.plate_reading.fluorescence import FluorescenceCapability +from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, PlateHolder, Resource + +from .absorbance_backend import CLARIOstarAbsorbanceBackend +from .driver import CLARIOstarDriver +from .fluorescence_backend import CLARIOstarFluorescenceBackend +from .luminescence_backend import CLARIOstarLuminescenceBackend + + +class CLARIOstar(Resource, Device): + """BMG Labtech CLARIOstar plate reader.""" + + def __init__( + self, + name: str, + device_id: Optional[str] = None, + size_x: float = 0.0, # TODO: measure + size_y: float = 0.0, # TODO: measure + size_z: float = 0.0, # TODO: measure + ): + driver = CLARIOstarDriver(device_id=device_id) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="BMG CLARIOstar", + ) + Device.__init__(self, driver=driver) + self._driver: CLARIOstarDriver = driver + self.absorbance = AbsorbanceCapability(backend=CLARIOstarAbsorbanceBackend(driver)) + self.luminescence = LuminescenceCapability(backend=CLARIOstarLuminescenceBackend(driver)) + self.fluorescence = FluorescenceCapability(backend=CLARIOstarFluorescenceBackend(driver)) + self._capabilities = [self.absorbance, self.luminescence, self.fluorescence] + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, # TODO: measure + size_y=85.48, # TODO: measure + size_z=0, + pedestal_size_z=0, + child_location=Coordinate.zero(), # TODO: measure + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} + + async def open(self) -> None: + """Open the plate tray.""" + await self._driver.open() + + async def close(self) -> None: + """Close the plate tray.""" + await self._driver.close() diff --git a/pylabrobot/bmg_labtech/clariostar/driver.py b/pylabrobot/bmg_labtech/clariostar/driver.py new file mode 100644 index 00000000000..d574c644125 --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/driver.py @@ -0,0 +1,200 @@ +import asyncio +import logging +import time +from typing import Optional, Union + +from pylabrobot.device import Driver +from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources.plate import Plate + +logger = logging.getLogger("pylabrobot") + + +class CLARIOstarDriver(Driver): + """FTDI-based driver for the BMG Labtech CLARIOstar plate reader. + + Owns the USB connection, low-level protocol framing, and device-level + operations (initialize, open/close tray). Communicates over FTDI USB + (VID 0x0403, PID 0xBB68) at 125000 baud. + """ + + def __init__(self, device_id: Optional[str] = None): + super().__init__() + self.io = FTDI( + human_readable_device_name="BMG CLARIOstar", device_id=device_id, vid=0x0403, pid=0xBB68 + ) + + async def setup(self) -> None: + await self.io.setup() + await self.io.set_baudrate(125000) + await self.io.set_line_property(8, 0, 0) # 8N1 + await self.io.set_latency_timer(2) + + await self._initialize() + await self._request_eeprom_data() + + async def stop(self) -> None: + await self.io.stop() + + # -- Low-level protocol --------------------------------------------------- + + async def read_resp(self, timeout: float = 20) -> bytes: + """Read a response terminated by 0x0D.""" + d = b"" + last_read = b"" + end_byte_found = False + t = time.time() + + while True: + last_read = await self.io.read(25) + if len(last_read) > 0: + d += last_read + end_byte_found = d[-1] == 0x0D + if len(last_read) < 25 and end_byte_found: + break + else: + if end_byte_found: + break + if time.time() - t > timeout: + logger.warning("timed out reading response") + break + await asyncio.sleep(0.0001) + + logger.debug("read %s", d.hex()) + return d + + async def send(self, cmd: Union[bytearray, bytes], read_timeout: float = 20) -> bytes: + """Send a command with 16-bit checksum + 0x0D terminator and return the response.""" + checksum = (sum(cmd) & 0xFFFF).to_bytes(2, byteorder="big") + cmd = cmd + checksum + b"\x0d" + logger.debug("sending %s", cmd.hex()) + w = await self.io.write(cmd) + logger.debug("wrote %s bytes", w) + assert w == len(cmd) + return await self.read_resp(timeout=read_timeout) + + async def _wait_for_ready_and_return(self, ret: bytes, timeout: float = 150) -> bytes: + """Poll command status until the device reports ready.""" + last_status = None + t = time.time() + while time.time() - t < timeout: + await asyncio.sleep(0.1) + command_status = await self._read_command_status() + + if len(command_status) != 24: + logger.warning( + "unexpected response %s. Expected 24 bytes for command status.", command_status + ) + continue + + if command_status != last_status: + logger.info("status changed %s", command_status.hex()) + last_status = command_status + else: + continue + + if command_status[2] != 0x18 or command_status[3] != 0x0C or command_status[4] != 0x01: + logger.warning("unexpected response header %s", command_status) + + if command_status[5] not in {0x25, 0x05}: + logger.warning("unexpected status byte %s", command_status) + + if command_status[5] == 0x05: + logger.debug("status is ready") + return ret + + raise TimeoutError("CLARIOstar did not become ready within timeout.") + + async def _read_command_status(self) -> bytes: + return await self.send(b"\x02\x00\x09\x0c\x80\x00") + + async def _initialize(self) -> None: + command_response = await self.send(b"\x02\x00\x0d\x0c\x01\x00\x00\x10\x02\x00") + await self._wait_for_ready_and_return(command_response) + + async def _request_eeprom_data(self) -> None: + eeprom_response = await self.send(b"\x02\x00\x0f\x0c\x05\x07\x00\x00\x00\x00\x00\x00") + await self._wait_for_ready_and_return(eeprom_response) + + # -- Tray control --------------------------------------------------------- + + async def open(self) -> None: + """Open the plate tray.""" + open_response = await self.send(b"\x02\x00\x0e\x0c\x03\x01\x00\x00\x00\x00\x00") + await self._wait_for_ready_and_return(open_response) + + async def close(self) -> None: + """Close the plate tray.""" + close_response = await self.send(b"\x02\x00\x0e\x0c\x03\x00\x00\x00\x00\x00\x00") + await self._wait_for_ready_and_return(close_response) + + # -- Helpers used by capability backends ---------------------------------- + + async def mp_and_focus_height_value(self) -> None: + resp = await self.send(b"\x02\x00\x0f\x0c\x05\17\x00\x00\x00\x00\x00\x00") + await self._wait_for_ready_and_return(resp) + + async def read_order_values(self) -> bytes: + return await self.send(b"\x02\x00\x0f\x0c\x05\x1d\x00\x00\x00\x00\x00\x00") + + async def status_hw(self) -> bytes: + resp = await self.send(b"\x02\x00\x09\x0c\x81\x00") + return await self._wait_for_ready_and_return(resp) + + async def get_measurement_values(self) -> bytes: + return await self.send(b"\x02\x00\x0f\x0c\x05\x02\x00\x00\x00\x00\x00\x00") + + def plate_bytes(self, plate: Plate) -> bytes: + """Encode plate geometry into the 62-byte binary format.""" + + def float_to_bytes(f: float) -> bytes: + return round(f * 100).to_bytes(2, byteorder="big") + + plate_length = plate.get_absolute_size_x() + plate_width = plate.get_absolute_size_y() + + well_0 = plate.get_well(0) + assert well_0.location is not None, "Well 0 must be assigned to a plate" + plate_x1 = well_0.location.x + well_0.center().x + plate_y1 = plate_width - (well_0.location.y + well_0.center().y) + plate_xn = plate_length - plate_x1 + plate_yn = plate_width - plate_y1 + + plate_cols = plate.num_items_x + plate_rows = plate.num_items_y + + wells = ([1] * plate.num_items) + ([0] * (384 - plate.num_items)) + well_mask: int = sum(b << i for i, b in enumerate(wells[::-1])) + wells_bytes = well_mask.to_bytes(48, "big") + + return ( + float_to_bytes(plate_length) + + float_to_bytes(plate_width) + + float_to_bytes(plate_x1) + + float_to_bytes(plate_y1) + + float_to_bytes(plate_xn) + + float_to_bytes(plate_yn) + + plate_cols.to_bytes(1, byteorder="big") + + plate_rows.to_bytes(1, byteorder="big") + + wells_bytes + ) + + async def run_measurement(self, payload: bytes) -> bytes: + """Execute a measurement run and poll until complete.""" + message_size = (len(payload) + 7).to_bytes(2, byteorder="big") + cmd = b"\x02" + message_size + b"\x0c" + payload + run_response = await self.send(cmd) + + last_status = None + while True: + await asyncio.sleep(0.1) + command_status = await self._read_command_status() + if command_status != last_status: + last_status = command_status + logger.info("status changed %s", command_status) + continue + if command_status == bytes( + b"\x02\x00\x18\x0c\x01\x25\x04\x2e\x00\x00\x04\x01\x00\x00\x03\x00" + b"\x00\x00\x00\xc0\x00\x01\x46\x0d" + ): + return run_response diff --git a/pylabrobot/bmg_labtech/clariostar/fluorescence_backend.py b/pylabrobot/bmg_labtech/clariostar/fluorescence_backend.py new file mode 100644 index 00000000000..fefc562e369 --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/fluorescence_backend.py @@ -0,0 +1,29 @@ +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.fluorescence import ( + FluorescenceBackend, + FluorescenceResult, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin + +from .driver import CLARIOstarDriver + + +class CLARIOstarFluorescenceBackend(FluorescenceBackend): + """Translates FluorescenceBackend interface into CLARIOstar driver commands.""" + + def __init__(self, driver: CLARIOstarDriver): + self._driver = driver + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[FluorescenceResult]: + raise NotImplementedError("CLARIOstar fluorescence reading is not implemented yet.") diff --git a/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py b/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py new file mode 100644 index 00000000000..6956b4b8cf3 --- /dev/null +++ b/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py @@ -0,0 +1,64 @@ +import struct +import time +from typing import List, Optional + +from pylabrobot.capabilities.plate_reading.luminescence import ( + LuminescenceBackend, + LuminescenceResult, +) +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.well import Well +from pylabrobot.serializer import SerializableMixin +from pylabrobot.utils.list import reshape_2d + +from .driver import CLARIOstarDriver + + +class CLARIOstarLuminescenceBackend(LuminescenceBackend): + """Translates LuminescenceBackend interface into CLARIOstar driver commands.""" + + def __init__(self, driver: CLARIOstarDriver): + self._driver = driver + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float = 13, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + if wells != plate.get_all_items(): + raise NotImplementedError("Only full plate reads are supported for now.") + + await self._driver.mp_and_focus_height_value() + + assert 0 <= focal_height <= 25, "focal height must be between 0 and 25 mm" + focal_height_data = int(focal_height * 100).to_bytes(2, byteorder="big") + plate_bytes = self._driver.plate_bytes(plate) + payload = ( + b"\x04" + plate_bytes + b"\x02\x01\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27" + b"\x0f\x27\x0f\x01" + focal_height_data + b"\x00\x00\x01\x00\x00\x0e\x10\x00\x01\x00\x01" + b"\x00\x01\x00\x01\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00" + b"\x00\x01\x00\x00\x00\x01\x00\x64\x00\x20\x00\x00" + ) + await self._driver.run_measurement(payload) + await self._driver.read_order_values() + await self._driver.status_hw() + + vals = await self._driver.get_measurement_values() + num_wells = plate.num_items + start_idx = vals.index(b"\x00\x00\x00\x00\x00\x00") + len(b"\x00\x00\x00\x00\x00\x00") + data = list(vals)[start_idx : start_idx + num_wells * 4] + int_bytes = [data[i : i + 4] for i in range(0, len(data), 4)] + ints = [struct.unpack(">i", bytes(int_data))[0] for int_data in int_bytes] + floats: List[List[Optional[float]]] = reshape_2d( + [float(i) for i in ints], (plate.num_items_y, plate.num_items_x) + ) + + return [ + LuminescenceResult( + data=floats, + temperature=None, + timestamp=time.time(), + ) + ] diff --git a/pylabrobot/capabilities/capability.py b/pylabrobot/capabilities/capability.py index bad76304ebe..2303bcd0a8b 100644 --- a/pylabrobot/capabilities/capability.py +++ b/pylabrobot/capabilities/capability.py @@ -20,7 +20,11 @@ class CapabilityBackend(ABC): """Base class for capability-specific backends.""" - pass + async def _on_setup(self): + """Called when the parent capability is set up.""" + + async def _on_stop(self): + """Called when the parent capability is stopped.""" def need_capability_ready(func: Callable[_P, _R]) -> Callable[_P, _R]: @@ -83,8 +87,10 @@ def setup_finished(self) -> bool: async def _on_setup(self): """Called by the parent Device after driver.setup() completes.""" + await self.backend._on_setup() self._setup_finished = True async def _on_stop(self): """Called by the parent Device before driver.stop().""" + await self.backend._on_stop() self._setup_finished = False diff --git a/pylabrobot/device.py b/pylabrobot/device.py index fe95603e013..c78c5cf762a 100644 --- a/pylabrobot/device.py +++ b/pylabrobot/device.py @@ -57,9 +57,6 @@ def get_all_instances(cls): return cls._instances -DeviceBackend = Driver # backward compat alias - - def need_setup_finished(func: Callable[_P, _R]) -> Callable[_P, _R]: """Decorator for methods that require the device to be set up. diff --git a/pylabrobot/hamilton/heater_shaker/__init__.py b/pylabrobot/hamilton/heater_shaker/__init__.py index 7a95a1d7037..c7e06a8dfca 100644 --- a/pylabrobot/hamilton/heater_shaker/__init__.py +++ b/pylabrobot/hamilton/heater_shaker/__init__.py @@ -1,3 +1,7 @@ -from .backend import HamiltonHeaterShakerBackend +from .backend import ( + HamiltonHeaterShakerDriver, + HamiltonHeaterShakerShakerBackend, + HamiltonHeaterShakerTemperatureBackend, +) from .box import HamiltonHeaterShakerBox, HamiltonHeaterShakerInterface from .heater_shaker import HamiltonHeaterShaker diff --git a/pylabrobot/hamilton/heater_shaker/backend.py b/pylabrobot/hamilton/heater_shaker/backend.py index 9eb3afe083d..c1f2714fd2f 100644 --- a/pylabrobot/hamilton/heater_shaker/backend.py +++ b/pylabrobot/hamilton/heater_shaker/backend.py @@ -15,35 +15,46 @@ class PlateLockPosition(Enum): UNLOCKED = 0 -class HamiltonHeaterShakerBackend(TemperatureControllerBackend, ShakerBackend, Driver): - """Backend for Hamilton Heater Shaker devices.""" +class HamiltonHeaterShakerDriver(Driver): + """Driver for Hamilton Heater Shaker devices. + + Owns the HamiltonHeaterShakerInterface I/O and setup/stop lifecycle. + Device-level operations (initialize lock, initialize shaker drive) live here. + """ def __init__(self, index: int, interface: HamiltonHeaterShakerInterface) -> None: + super().__init__() assert index >= 0, "Shaker index must be non-negative" self.index = index self.interface = interface - @property - def supports_active_cooling(self) -> bool: - return False - async def setup(self): - await Driver.setup(self) - await self._initialize_lock() - await self._initialize_shaker_drive() + pass async def stop(self): - await Driver.stop(self) + pass def serialize(self) -> dict: warnings.warn("The interface is not serialized.") return { - **Driver.serialize(self), + **super().serialize(), "index": self.index, "interface": None, } - # -- shaking -- + async def send_command(self, command: str, **kwargs) -> str: + """Send a command to the heater shaker via the interface.""" + return await self.interface.send_hhs_command(index=self.index, command=command, **kwargs) + + +class HamiltonHeaterShakerShakerBackend(ShakerBackend): + """Translates ShakerBackend interface into Hamilton Heater Shaker driver commands.""" + + def __init__(self, driver: HamiltonHeaterShakerDriver) -> None: + self._driver = driver + + async def _on_setup(self): + await self._driver.send_command("SI") async def start_shaking( self, @@ -72,11 +83,11 @@ async def stop_shaking(self): await self._wait_for_stop() async def get_is_shaking(self) -> bool: - response = await self.interface.send_hhs_command(index=self.index, command="RD") + response = await self._driver.send_command("RD") return response.endswith("1") async def _move_plate_lock(self, position: PlateLockPosition): - return await self.interface.send_hhs_command(index=self.index, command="LP", lp=position.value) + return await self._driver.send_command("LP", lp=position.value) @property def supports_locking(self) -> bool: @@ -88,34 +99,39 @@ async def lock_plate(self): async def unlock_plate(self): await self._move_plate_lock(PlateLockPosition.UNLOCKED) - async def _initialize_lock(self): - return await self.interface.send_hhs_command(index=self.index, command="LI") - - async def _initialize_shaker_drive(self): - return await self.interface.send_hhs_command(index=self.index, command="SI") - async def _start_shaking(self, direction: int, speed: int, acceleration: int): speed_str = str(speed).zfill(4) acceleration_str = str(acceleration).zfill(5) - return await self.interface.send_hhs_command( - index=self.index, command="SB", st=direction, sv=speed_str, sr=acceleration_str - ) + return await self._driver.send_command("SB", st=direction, sv=speed_str, sr=acceleration_str) async def _stop_shaking(self): - return await self.interface.send_hhs_command(index=self.index, command="SC") + return await self._driver.send_command("SC") async def _wait_for_stop(self): - return await self.interface.send_hhs_command(index=self.index, command="SW") + return await self._driver.send_command("SW") + + +class HamiltonHeaterShakerTemperatureBackend(TemperatureControllerBackend): + """Translates TemperatureControllerBackend interface into Hamilton Heater Shaker driver + commands.""" + + def __init__(self, driver: HamiltonHeaterShakerDriver) -> None: + self._driver = driver - # -- temperature -- + async def _on_setup(self): + await self._driver.send_command("LI") + + @property + def supports_active_cooling(self) -> bool: + return False async def set_temperature(self, temperature: float): assert 0 < temperature <= 105 temp_str = f"{round(10 * temperature):04d}" - return await self.interface.send_hhs_command(index=self.index, command="TA", ta=temp_str) + return await self._driver.send_command("TA", ta=temp_str) async def _get_current_temperature(self) -> Dict[str, float]: - response = await self.interface.send_hhs_command(index=self.index, command="RT") + response = await self._driver.send_command("RT") response = response.split("rt")[1] middle_temp = float(str(response).split(" ")[0].strip("+")) / 10 edge_temp = float(str(response).split(" ")[1].strip("+")) / 10 @@ -130,4 +146,4 @@ async def get_edge_temperature(self) -> float: return response["edge"] async def deactivate(self): - return await self.interface.send_hhs_command(index=self.index, command="TO") + return await self._driver.send_command("TO") diff --git a/pylabrobot/hamilton/heater_shaker/heater_shaker.py b/pylabrobot/hamilton/heater_shaker/heater_shaker.py index 3de7848b4a9..363f0193cb7 100644 --- a/pylabrobot/hamilton/heater_shaker/heater_shaker.py +++ b/pylabrobot/hamilton/heater_shaker/heater_shaker.py @@ -6,7 +6,12 @@ from pylabrobot.resources import Coordinate from pylabrobot.resources.carrier import PlateHolder -from .backend import HamiltonHeaterShakerBackend +from .backend import ( + HamiltonHeaterShakerDriver, + HamiltonHeaterShakerShakerBackend, + HamiltonHeaterShakerTemperatureBackend, +) +from .box import HamiltonHeaterShakerInterface class HamiltonHeaterShaker(PlateHolder, Device): @@ -15,7 +20,8 @@ class HamiltonHeaterShaker(PlateHolder, Device): def __init__( self, name: str, - driver: HamiltonHeaterShakerBackend, + index: int, + interface: HamiltonHeaterShakerInterface, size_x: float = 146.2, size_y: float = 103.6, size_z: float = 74.11, @@ -25,6 +31,7 @@ def __init__( model: Optional[str] = None, ): raise NotImplementedError("HamiltonHeaterShaker resource definition is not verified.") + driver = HamiltonHeaterShakerDriver(index=index, interface=interface) PlateHolder.__init__( self, name=name, @@ -37,9 +44,9 @@ def __init__( model=model, ) Device.__init__(self, driver=driver) - self._driver: HamiltonHeaterShakerBackend = driver - self.tc = TemperatureControlCapability(backend=driver) - self.shaker = ShakingCapability(backend=driver) + self._driver: HamiltonHeaterShakerDriver = driver + self.tc = TemperatureControlCapability(backend=HamiltonHeaterShakerTemperatureBackend(driver)) + self.shaker = ShakingCapability(backend=HamiltonHeaterShakerShakerBackend(driver)) self._capabilities = [self.tc, self.shaker] def serialize(self) -> dict: diff --git a/pylabrobot/hamilton/only_fans/__init__.py b/pylabrobot/hamilton/only_fans/__init__.py index ea293e10da9..d3b71629c3d 100644 --- a/pylabrobot/hamilton/only_fans/__init__.py +++ b/pylabrobot/hamilton/only_fans/__init__.py @@ -1,2 +1,6 @@ -from .backend import HamiltonHepaFanBackend, HamiltonHepaFanChatterboxBackend +from .backend import ( + HamiltonHepaFanChatterboxBackend, + HamiltonHepaFanDriver, + HamiltonHepaFanFanBackend, +) from .hepa_fan import HamiltonHepaFan diff --git a/pylabrobot/hamilton/only_fans/backend.py b/pylabrobot/hamilton/only_fans/backend.py index e83fbbd3eb5..742e9538278 100644 --- a/pylabrobot/hamilton/only_fans/backend.py +++ b/pylabrobot/hamilton/only_fans/backend.py @@ -5,9 +5,113 @@ from pylabrobot.device import Driver from pylabrobot.io.ftdi import FTDI - -class HamiltonHepaFanBackend(FanBackend, Driver): - """Backend for the Hamilton HEPA fan attachment.""" +_SPEED_TABLE = [ + "55c10111007b", + "55c101110279", + "55c10111057e", + "55c10111077c", + "55c101110a71", + "55c101110c77", + "55c101110f74", + "55c10111116a", + "55c10111146f", + "55c10111166d", + "55c101111962", + "55c101111c67", + "55c101111e65", + "55c10111215a", + "55c101112358", + "55c10111265d", + "55c101112853", + "55c101112b50", + "55c101112d56", + "55c10111304b", + "55c101113249", + "55c10111354e", + "55c101113843", + "55c101113a41", + "55c101113d46", + "55c101113f44", + "55c101114239", + "55c10111443f", + "55c10111473c", + "55c101114932", + "55c101114c37", + "55c101114f34", + "55c10111512a", + "55c10111542f", + "55c10111562d", + "55c101115922", + "55c101115b20", + "55c101115e25", + "55c10111601b", + "55c101116318", + "55c10111651e", + "55c101116813", + "55c101116b10", + "55c101116d16", + "55c10111700b", + "55c101117209", + "55c10111750e", + "55c10111770c", + "55c101117a01", + "55c101117c07", + "55c101117f04", + "55c1011182f9", + "55c1011184ff", + "55c1011187fc", + "55c1011189f2", + "55c101118cf7", + "55c101118ef5", + "55c1011191ea", + "55c1011193e8", + "55c1011196ed", + "55c1011198e3", + "55c101119be0", + "55c101119ee5", + "55c10111a0db", + "55c10111a3d8", + "55c10111a5de", + "55c10111a8d3", + "55c10111aad1", + "55c10111add6", + "55c10111afd4", + "55c10111b2c9", + "55c10111b5ce", + "55c10111b7cc", + "55c10111bac1", + "55c10111bcc7", + "55c10111bfc4", + "55c10111c1ba", + "55c10111c4bf", + "55c10111c6bd", + "55c10111c9b2", + "55c10111cbb0", + "55c10111ceb5", + "55c10111d1aa", + "55c10111d3a8", + "55c10111d6ad", + "55c10111d8a3", + "55c10111dba0", + "55c10111dda6", + "55c10111e09b", + "55c10111e299", + "55c10111e59e", + "55c10111e893", + "55c10111ea91", + "55c10111ed96", + "55c10111ef94", + "55c10111f289", + "55c10111f48f", + "55c10111f78c", + "55c10111f982", + "55c10111fc87", + "55c10111fe85", +] + + +class HamiltonHepaFanDriver(Driver): + """FTDI driver for the Hamilton HEPA fan.""" def __init__(self, device_id: Optional[str] = None): self.io = FTDI( @@ -26,145 +130,42 @@ async def setup(self): await self.io.set_dtr(True) await self.io.set_rts(True) - await self._send(b"\x55\xc1\x01\x02\x23\x4b") - await self._send(b"\x55\xc1\x01\x08\x08\x6a") - await self._send(b"\x55\xc1\x01\x09\x6a\x09") - await self._send(b"\x55\xc1\x01\x0a\x2f\x4f") - await self._send(b"\x15\x61\x01\x8a") + await self.send(b"\x55\xc1\x01\x02\x23\x4b") + await self.send(b"\x55\xc1\x01\x08\x08\x6a") + await self.send(b"\x55\xc1\x01\x09\x6a\x09") + await self.send(b"\x55\xc1\x01\x0a\x2f\x4f") + await self.send(b"\x15\x61\x01\x8a") async def stop(self): await self.io.stop() - _SPEED_TABLE = [ - "55c10111007b", - "55c101110279", - "55c10111057e", - "55c10111077c", - "55c101110a71", - "55c101110c77", - "55c101110f74", - "55c10111116a", - "55c10111146f", - "55c10111166d", - "55c101111962", - "55c101111c67", - "55c101111e65", - "55c10111215a", - "55c101112358", - "55c10111265d", - "55c101112853", - "55c101112b50", - "55c101112d56", - "55c10111304b", - "55c101113249", - "55c10111354e", - "55c101113843", - "55c101113a41", - "55c101113d46", - "55c101113f44", - "55c101114239", - "55c10111443f", - "55c10111473c", - "55c101114932", - "55c101114c37", - "55c101114f34", - "55c10111512a", - "55c10111542f", - "55c10111562d", - "55c101115922", - "55c101115b20", - "55c101115e25", - "55c10111601b", - "55c101116318", - "55c10111651e", - "55c101116813", - "55c101116b10", - "55c101116d16", - "55c10111700b", - "55c101117209", - "55c10111750e", - "55c10111770c", - "55c101117a01", - "55c101117c07", - "55c101117f04", - "55c1011182f9", - "55c1011184ff", - "55c1011187fc", - "55c1011189f2", - "55c101118cf7", - "55c101118ef5", - "55c1011191ea", - "55c1011193e8", - "55c1011196ed", - "55c1011198e3", - "55c101119be0", - "55c101119ee5", - "55c10111a0db", - "55c10111a3d8", - "55c10111a5de", - "55c10111a8d3", - "55c10111aad1", - "55c10111add6", - "55c10111afd4", - "55c10111b2c9", - "55c10111b5ce", - "55c10111b7cc", - "55c10111bac1", - "55c10111bcc7", - "55c10111bfc4", - "55c10111c1ba", - "55c10111c4bf", - "55c10111c6bd", - "55c10111c9b2", - "55c10111cbb0", - "55c10111ceb5", - "55c10111d1aa", - "55c10111d3a8", - "55c10111d6ad", - "55c10111d8a3", - "55c10111dba0", - "55c10111dda6", - "55c10111e09b", - "55c10111e299", - "55c10111e59e", - "55c10111e893", - "55c10111ea91", - "55c10111ed96", - "55c10111ef94", - "55c10111f289", - "55c10111f48f", - "55c10111f78c", - "55c10111f982", - "55c10111fc87", - "55c10111fe85", - ] + async def send(self, command: bytes): + await self.io.write(command) + await asyncio.sleep(0.1) + await self.io.read(64) + + +class HamiltonHepaFanFanBackend(FanBackend): + """Translates FanBackend calls into FTDI commands.""" + + def __init__(self, driver: HamiltonHepaFanDriver): + self._driver = driver async def turn_on(self, intensity: int) -> None: if int(intensity) != intensity or not 0 <= intensity <= 100: raise ValueError("Intensity must be an integer between 0 and 100") - await self._send(b"\x35\x41\x01\xff\x75") # turn on - await self._send(bytes.fromhex(self._SPEED_TABLE[intensity])) # set speed + await self._driver.send(b"\x35\x41\x01\xff\x75") + await self._driver.send(bytes.fromhex(_SPEED_TABLE[intensity])) async def turn_off(self) -> None: - await self._send(b"\x55\xc1\x01\x11\x00\x7b") + await self._driver.send(b"\x55\xc1\x01\x11\x00\x7b") - async def _send(self, command: bytes): - await self.io.write(command) - await asyncio.sleep(0.1) - await self.io.read(64) - -class HamiltonHepaFanChatterboxBackend(FanBackend, Driver): +class HamiltonHepaFanChatterboxBackend(FanBackend): """Chatterbox backend for device-free testing.""" - async def setup(self) -> None: - pass - async def turn_on(self, intensity: int) -> None: pass async def turn_off(self) -> None: pass - - async def stop(self) -> None: - pass diff --git a/pylabrobot/hamilton/only_fans/hepa_fan.py b/pylabrobot/hamilton/only_fans/hepa_fan.py index e09c6e7e3c0..c533bef52a6 100644 --- a/pylabrobot/hamilton/only_fans/hepa_fan.py +++ b/pylabrobot/hamilton/only_fans/hepa_fan.py @@ -3,15 +3,15 @@ from pylabrobot.capabilities.fan_control import FanControlCapability from pylabrobot.device import Device -from .backend import HamiltonHepaFanBackend +from .backend import HamiltonHepaFanDriver, HamiltonHepaFanFanBackend class HamiltonHepaFan(Device): """Hamilton HEPA fan attachment.""" def __init__(self, name: str, device_id: Optional[str] = None): - backend = HamiltonHepaFanBackend(device_id=device_id) - super().__init__(driver=backend) - self._driver: HamiltonHepaFanBackend = backend - self.fan = FanControlCapability(backend=backend) + driver = HamiltonHepaFanDriver(device_id=device_id) + super().__init__(driver=driver) + self._driver: HamiltonHepaFanDriver = driver + self.fan = FanControlCapability(backend=HamiltonHepaFanFanBackend(driver)) self._capabilities = [self.fan] diff --git a/pylabrobot/hamilton/tilt_module/__init__.py b/pylabrobot/hamilton/tilt_module/__init__.py index 6d271987219..650fa1298e4 100644 --- a/pylabrobot/hamilton/tilt_module/__init__.py +++ b/pylabrobot/hamilton/tilt_module/__init__.py @@ -1,2 +1,6 @@ -from .backend import HamiltonTiltModuleBackend, HamiltonTiltModuleChatterboxBackend +from .backend import ( + HamiltonTiltModuleChatterboxTilterBackend, + HamiltonTiltModuleDriver, + HamiltonTiltModuleTilterBackend, +) from .tilt_module import HamiltonTiltModule diff --git a/pylabrobot/hamilton/tilt_module/backend.py b/pylabrobot/hamilton/tilt_module/backend.py index cd8c867038e..d8df9686f07 100644 --- a/pylabrobot/hamilton/tilt_module/backend.py +++ b/pylabrobot/hamilton/tilt_module/backend.py @@ -14,8 +14,11 @@ from pylabrobot.io.serial import Serial -class HamiltonTiltModuleBackend(TilterBackend, Driver): - """Backend for the Hamilton tilt module.""" +class HamiltonTiltModuleDriver(Driver): + """Serial driver for the Hamilton tilt module. + + Owns the hardware connection. Knows how to send bytes on the wire. + """ def __init__( self, @@ -29,6 +32,7 @@ def __init__( f"Import error: {_SERIAL_IMPORT_ERROR}" ) + super().__init__() self.com_port = com_port self.io = Serial( port=self.com_port, @@ -41,10 +45,8 @@ def __init__( human_readable_device_name="Hamilton Tilt Module", ) - async def setup(self, initial_offset: int = 0): + async def setup(self): await self.io.setup() - await self.tilt_initial_offset(initial_offset) - await self.tilt_initialize() async def stop(self): await self.io.stop() @@ -80,6 +82,21 @@ async def send_command(self, command: str, parameter: Optional[str] = None) -> s return resp + +class HamiltonTiltModuleTilterBackend(TilterBackend): + """Translates TilterBackend interface into Hamilton tilt module driver commands. + + Protocol encoding lives here -- the backend knows that set_angle means + calling tilt_go_to_position via the driver's send_command. + """ + + def __init__(self, driver: HamiltonTiltModuleDriver): + self._driver = driver + + async def _on_setup(self): + await self.tilt_initial_offset(0) + await self.tilt_initialize() + async def set_angle(self, angle: float): """Set the tilt module to rotate by a given angle.""" @@ -90,7 +107,7 @@ async def set_angle(self, angle: float): async def tilt_initialize(self): """Initialize a daisy chained tilt module.""" - return await self.send_command("SI") + return await self._driver.send_command("SI") async def tilt_move_to_absolute_step_position(self, position: float): """Move the tilt module to an absolute position. @@ -101,7 +118,7 @@ async def tilt_move_to_absolute_step_position(self, position: float): assert -10 <= position <= 120, "Position must be between -10 and 120." - return await self.send_command( + return await self._driver.send_command( command="SA", parameter=str(position), ) @@ -117,7 +134,7 @@ async def tilt_move_to_relative_step_position(self, steps: float): assert -10000 <= steps <= 10000, "Steps must be between -10000 and 10000." - return await self.send_command(command="SR", parameter=str(steps)) + return await self._driver.send_command(command="SR", parameter=str(steps)) async def tilt_go_to_position(self, position: int): """Go to position (0...10). @@ -128,7 +145,7 @@ async def tilt_go_to_position(self, position: int): assert 0 <= position <= 10, "Position must be between 0 and 10." - return await self.send_command(command="GP", parameter=str(position)) + return await self._driver.send_command(command="GP", parameter=str(position)) async def tilt_set_speed(self, speed: int): """Set the speed on the tilt module. @@ -139,12 +156,12 @@ async def tilt_set_speed(self, speed: int): assert 1 <= speed <= 9, "Speed must be between 1 and 9." - return await self.send_command(command="SV", parameter=str(speed)) + return await self._driver.send_command(command="SV", parameter=str(speed)) async def tilt_power_off(self): """Power off the tilt module.""" - return await self.send_command(command="PO") + return await self._driver.send_command(command="PO") async def tilt_request_error(self) -> Optional[str]: """Request the error of the tilt module. @@ -153,7 +170,7 @@ async def tilt_request_error(self) -> Optional[str]: """ # send_command will automatically raise an error, if one exists - return await self.send_command("RE") + return await self._driver.send_command("RE") async def tilt_request_sensor(self) -> Optional[str]: """Request sensor status. @@ -163,7 +180,7 @@ async def tilt_request_sensor(self) -> Optional[str]: 6 = NPN Input 1, 7 = NPN Input 2 """ - resp = await self.send_command(command="RX") + resp = await self._driver.send_command(command="RX") resp = resp[:-2].split(" ")[1] code = int(resp) @@ -185,7 +202,7 @@ async def tilt_request_sensor(self) -> Optional[str]: async def tilt_request_offset_between_light_barrier_and_init_position(self) -> int: """Request Offset between Light Barrier and Init Position""" - resp = await self.send_command(command="RO") + resp = await self._driver.send_command(command="RO") resp = resp[:-2].split(" ")[1] return int(resp) @@ -198,7 +215,7 @@ async def tilt_port_set_open_collector(self, open_collector: int): assert 1 <= open_collector <= 8, "open_collector must be between 1 and 8" - return await self.send_command(command="PS", parameter=str(open_collector)) + return await self._driver.send_command(command="PS", parameter=str(open_collector)) async def tilt_port_clear_open_collector(self, open_collector: int): """Tilt port clear open collector. @@ -209,7 +226,7 @@ async def tilt_port_clear_open_collector(self, open_collector: int): assert 1 <= open_collector <= 8, "open_collector must be between 1 and 8" - return await self.send_command(command="PC", parameter=str(open_collector)) + return await self._driver.send_command(command="PC", parameter=str(open_collector)) async def tilt_set_temperature(self, temperature: float): """Set the temperature (10-50 degrees C). @@ -220,12 +237,12 @@ async def tilt_set_temperature(self, temperature: float): assert 10 <= temperature <= 50, "Temperature must be between 10 and 50." - return await self.send_command(command="ST", parameter=str(int(temperature * 10))) + return await self._driver.send_command(command="ST", parameter=str(int(temperature * 10))) async def tilt_switch_off_temperature_controller(self): """Switch off the temperature controller on the tilt module.""" - return await self.send_command(command="TO") + return await self._driver.send_command(command="TO") async def tilt_set_drain_time(self, drain_time: float): """Set the drain time on the tilt module. @@ -236,17 +253,17 @@ async def tilt_set_drain_time(self, drain_time: float): assert 5 <= drain_time <= 250, "Drain time must be between 5 and 250." - return await self.send_command(command="DT", parameter=str(int(drain_time * 10))) + return await self._driver.send_command(command="DT", parameter=str(int(drain_time * 10))) async def tilt_set_waste_pump_on(self): """Turn the waste pump on.""" - return await self.send_command(command="WP") + return await self._driver.send_command(command="WP") async def tilt_set_waste_pump_off(self): """Turn the waste pump off.""" - return await self.send_command(command="WO") + return await self._driver.send_command(command="WO") async def tilt_set_name(self, name: str): """Set the tilt module name. @@ -257,7 +274,7 @@ async def tilt_set_name(self, name: str): assert len(name) == 2, "name must be 2 characters long" - return await self.send_command(command="MN", parameter=name) + return await self._driver.send_command(command="MN", parameter=name) async def tilt_switch_encoder(self, on: bool): """Switch the encoder on or off. @@ -266,7 +283,7 @@ async def tilt_switch_encoder(self, on: bool): on: if True, the encoder will be turned on, else off. """ - return await self.send_command(command="EN", parameter=str(int(on))) + return await self._driver.send_command(command="EN", parameter=str(int(on))) async def tilt_initial_offset(self, offset: int): """Set the initial offset on the tilt module. @@ -277,15 +294,11 @@ async def tilt_initial_offset(self, offset: int): assert -100 <= offset <= 100, "Offset must be between -100 and 100." - return await self.send_command(command="SO", parameter=str(offset)) - + return await self._driver.send_command(command="SO", parameter=str(offset)) -class HamiltonTiltModuleChatterboxBackend(HamiltonTiltModuleBackend): - async def setup(self, initial_offset=0): - pass - async def stop(self): - pass +class HamiltonTiltModuleChatterboxTilterBackend(TilterBackend): + """No-op backend for testing without hardware.""" - async def send_command(self, command, parameter=None): + async def set_angle(self, angle: float): pass diff --git a/pylabrobot/hamilton/tilt_module/tilt_module.py b/pylabrobot/hamilton/tilt_module/tilt_module.py index 606d30d4776..70d56df8492 100644 --- a/pylabrobot/hamilton/tilt_module/tilt_module.py +++ b/pylabrobot/hamilton/tilt_module/tilt_module.py @@ -7,7 +7,7 @@ from pylabrobot.resources.resource_holder import ResourceHolder from pylabrobot.resources.well import CrossSectionType, Well -from .backend import HamiltonTiltModuleBackend +from .backend import HamiltonTiltModuleDriver, HamiltonTiltModuleTilterBackend class HamiltonTiltModule(ResourceHolder, Device): @@ -22,7 +22,7 @@ def __init__( write_timeout: float = 3, timeout: float = 3, ): - backend = HamiltonTiltModuleBackend( + driver = HamiltonTiltModuleDriver( com_port=com_port, write_timeout=write_timeout, timeout=timeout, @@ -37,12 +37,12 @@ def __init__( category="tilter", model="HamiltonTiltModule", ) - Device.__init__(self, driver=backend) - self._driver: HamiltonTiltModuleBackend = backend + Device.__init__(self, driver=driver) + self._driver: HamiltonTiltModuleDriver = driver self.pedestal_size_z = pedestal_size_z self._hinge_coordinate = Coordinate(6.18, 0, 72.85) - self.tilter = TiltingCapability(backend=backend) + self.tilter = TiltingCapability(backend=HamiltonTiltModuleTilterBackend(driver=driver)) self._capabilities = [self.tilter] @property diff --git a/pylabrobot/inheco/scila/__init__.py b/pylabrobot/inheco/scila/__init__.py index c3f41eff14a..8b35092daa8 100644 --- a/pylabrobot/inheco/scila/__init__.py +++ b/pylabrobot/inheco/scila/__init__.py @@ -1,3 +1,3 @@ from .inheco_sila_interface import InhecoSiLAInterface from .scila import SCILA -from .scila_backend import DrawerStatus, SCILABackend +from .scila_backend import DrawerStatus, SCILADriver, SCILATemperatureBackend diff --git a/pylabrobot/inheco/scila/scila.py b/pylabrobot/inheco/scila/scila.py index 00c886781fe..30b8880266e 100644 --- a/pylabrobot/inheco/scila/scila.py +++ b/pylabrobot/inheco/scila/scila.py @@ -1,17 +1,20 @@ +from typing import Optional + from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability from pylabrobot.device import Device -from .scila_backend import SCILABackend +from .scila_backend import SCILADriver, SCILATemperatureBackend class SCILA(Device): """Inheco SCILA incubator with 4 drawers and temperature control.""" - def __init__(self, name: str, driver: SCILABackend): + def __init__(self, name: str, scila_ip: str, client_ip: Optional[str] = None): raise NotImplementedError("SCILA is missing resource definition.") + driver = SCILADriver(scila_ip=scila_ip, client_ip=client_ip) Device.__init__(self, driver=driver) - self._driver: SCILABackend = driver - self.tc = TemperatureControlCapability(backend=driver) + self._driver: SCILADriver = driver + self.tc = TemperatureControlCapability(backend=SCILATemperatureBackend(driver=driver)) self._capabilities = [self.tc] def serialize(self) -> dict: diff --git a/pylabrobot/inheco/scila/scila_backend.py b/pylabrobot/inheco/scila/scila_backend.py index dd05d24012d..6caf1f97a14 100644 --- a/pylabrobot/inheco/scila/scila_backend.py +++ b/pylabrobot/inheco/scila/scila_backend.py @@ -36,39 +36,43 @@ def _get_params(root: ET.Element, names: list[str]) -> dict[str, object]: DrawerStatus = Literal["Opened", "Closed"] -class SCILABackend(TemperatureControllerBackend, Driver): - """Backend for Inheco SciLa incubators. +class SCILADriver(Driver): + """Hardware driver for Inheco SCILA incubators. - Communicates over HTTP/SOAP via the SiLA interface. + Owns the SiLA HTTP/SOAP connection and exposes generic send_command(), + plus device-level operations (drawers, status, CO2/valves). """ def __init__(self, scila_ip: str, client_ip: Optional[str] = None) -> None: + super().__init__() self._sila_interface = InhecoSiLAInterface(client_ip=client_ip, machine_ip=scila_ip) async def setup(self) -> None: - await Driver.setup(self) await self._sila_interface.setup() await self._reset_and_initialize() async def stop(self) -> None: - await Driver.stop(self) await self._sila_interface.close() + async def send_command(self, command: str, **kwargs) -> Any: + """Send a SiLA command and return the parsed response.""" + return await self._sila_interface.send_command(command, **kwargs) + async def _reset_and_initialize(self) -> None: event_uri = f"http://{self._sila_interface.client_ip}:{self._sila_interface.bound_port}/" - await self._sila_interface.send_command( + await self.send_command( command="Reset", deviceId="MyController", eventReceiverURI=event_uri, simulationMode=False ) - await self._sila_interface.send_command("Initialize") + await self.send_command("Initialize") # -- status queries -- async def request_status(self) -> str: - resp = await self._sila_interface.send_command("GetStatus") + resp = await self.send_command("GetStatus") return resp.get("GetStatusResponse", {}).get("state", "Unknown") # type: ignore async def request_liquid_level(self) -> str: - root = await self._sila_interface.send_command("GetLiquidLevel") + root = await self.send_command("GetLiquidLevel") return _get_param(root, "LiquidLevel") # type: ignore # -- drawers -- @@ -76,17 +80,17 @@ async def request_liquid_level(self) -> str: async def open(self, drawer_id: int) -> None: if drawer_id not in {1, 2, 3, 4}: raise ValueError(f"Invalid drawer ID: {drawer_id}. Must be 1, 2, 3, or 4.") - await self._sila_interface.send_command("PrepareForInput", position=drawer_id) - await self._sila_interface.send_command("OpenDoor") + await self.send_command("PrepareForInput", position=drawer_id) + await self.send_command("OpenDoor") async def close(self, drawer_id: int) -> None: if drawer_id not in {1, 2, 3, 4}: raise ValueError(f"Invalid drawer ID: {drawer_id}. Must be 1, 2, 3, or 4.") - await self._sila_interface.send_command("PrepareForOutput", position=drawer_id) - await self._sila_interface.send_command("CloseDoor") + await self.send_command("PrepareForOutput", position=drawer_id) + await self.send_command("CloseDoor") async def request_drawer_statuses(self) -> Dict[int, DrawerStatus]: - root = await self._sila_interface.send_command("GetDoorStatus") + root = await self.send_command("GetDoorStatus") params = _get_params(root, ["Drawer1", "Drawer2", "Drawer3", "Drawer4"]) return {i: params[f"Drawer{i}"] for i in range(1, 5)} # type: ignore @@ -99,25 +103,43 @@ async def request_drawer_status(self, drawer_id: int) -> DrawerStatus: # -- CO2 / valves -- async def request_co2_flow_status(self) -> str: - root = await self._sila_interface.send_command("GetCO2FlowStatus") + root = await self.send_command("GetCO2FlowStatus") return _get_param(root, "CO2FlowStatus") # type: ignore async def request_valve_status(self) -> dict[str, str]: - root = await self._sila_interface.send_command("GetValveStatus") + root = await self.send_command("GetValveStatus") return _get_params(root, ["H2O", "CO2 Normal", "CO2 Boost"]) # type: ignore - # -- TemperatureControllerBackend -- + # -- serialization -- + + def serialize(self) -> dict[str, Any]: + return { + **super().serialize(), + "scila_ip": self._sila_interface.machine_ip, + "client_ip": self._sila_interface.client_ip, + } + + @classmethod + def deserialize(cls, data: dict[str, Any]) -> "SCILADriver": + return cls(scila_ip=data["scila_ip"], client_ip=data.get("client_ip")) + + +class SCILATemperatureBackend(TemperatureControllerBackend): + """Translates TemperatureControllerBackend interface into SCILA SiLA commands.""" + + def __init__(self, driver: SCILADriver) -> None: + self._driver = driver @property def supports_active_cooling(self) -> bool: return False async def request_temperature_information(self) -> dict[str, Any]: - root = await self._sila_interface.send_command("GetTemperature") + root = await self._driver.send_command("GetTemperature") return _get_params(root, ["CurrentTemperature", "TargetTemperature", "TemperatureControl"]) # type: ignore async def set_temperature(self, temperature: float) -> None: - await self._sila_interface.send_command( + await self._driver.send_command( "SetTemperature", targetTemperature=temperature, temperatureControl=True ) @@ -125,23 +147,10 @@ async def get_current_temperature(self) -> float: return (await self.request_temperature_information())["CurrentTemperature"] # type: ignore async def deactivate(self) -> None: - await self._sila_interface.send_command("SetTemperature", temperatureControl=False) + await self._driver.send_command("SetTemperature", temperatureControl=False) async def request_target_temperature(self) -> float: return (await self.request_temperature_information())["TargetTemperature"] # type: ignore async def is_temperature_control_enabled(self) -> bool: return (await self.request_temperature_information())["TemperatureControl"] # type: ignore - - # -- serialization -- - - def serialize(self) -> dict[str, Any]: - return { - **super().serialize(), - "scila_ip": self._sila_interface.machine_ip, - "client_ip": self._sila_interface.client_ip, - } - - @classmethod - def deserialize(cls, data: dict[str, Any]) -> "SCILABackend": - return cls(scila_ip=data["scila_ip"], client_ip=data.get("client_ip")) diff --git a/pylabrobot/inheco/scila/scila_backend_tests.py b/pylabrobot/inheco/scila/scila_backend_tests.py index 9bc7570d0b4..3c61fd78224 100644 --- a/pylabrobot/inheco/scila/scila_backend_tests.py +++ b/pylabrobot/inheco/scila/scila_backend_tests.py @@ -3,10 +3,10 @@ from unittest.mock import AsyncMock, patch from pylabrobot.inheco.scila.inheco_sila_interface import InhecoSiLAInterface -from pylabrobot.inheco.scila.scila_backend import SCILABackend +from pylabrobot.inheco.scila.scila_backend import SCILADriver, SCILATemperatureBackend -class TestSCILABackend(unittest.IsolatedAsyncioTestCase): +class TestSCILADriver(unittest.IsolatedAsyncioTestCase): def setUp(self): self.patcher = patch("pylabrobot.inheco.scila.scila_backend.InhecoSiLAInterface") self.MockInhecoSiLAInterface = self.patcher.start() @@ -14,16 +14,16 @@ def setUp(self): self.mock_sila_interface.bound_port = 80 self.mock_sila_interface.client_ip = "127.0.0.1" self.MockInhecoSiLAInterface.return_value = self.mock_sila_interface - self.backend = SCILABackend(scila_ip="127.0.0.1") + self.driver = SCILADriver(scila_ip="127.0.0.1") def tearDown(self): self.patcher.stop() async def test_setup(self): - await self.backend.setup() + await self.driver.setup() self.mock_sila_interface.setup.assert_called_once() self.mock_sila_interface.send_command.assert_any_call( - command="Reset", + "Reset", deviceId="MyController", eventReceiverURI="http://127.0.0.1:80/", simulationMode=False, @@ -31,13 +31,13 @@ async def test_setup(self): self.mock_sila_interface.send_command.assert_any_call("Initialize") async def test_stop(self): - await self.backend.setup() - await self.backend.stop() + await self.driver.setup() + await self.driver.stop() self.mock_sila_interface.close.assert_called_once() async def test_request_status(self): self.mock_sila_interface.send_command.return_value = {"GetStatusResponse": {"state": "standBy"}} - status = await self.backend.request_status() + status = await self.driver.request_status() self.assertEqual(status, "standBy") self.mock_sila_interface.send_command.assert_called_with("GetStatus") @@ -45,74 +45,27 @@ async def test_request_liquid_level(self): self.mock_sila_interface.send_command.return_value = ET.fromstring( "High" ) - level = await self.backend.request_liquid_level() + level = await self.driver.request_liquid_level() self.assertEqual(level, "High") self.mock_sila_interface.send_command.assert_called_with("GetLiquidLevel") - async def test_request_temperature_information(self): - self.mock_sila_interface.send_command.return_value = ET.fromstring( - "" - " 25.0" - " 37.0" - " true" - "" - ) - info = await self.backend.request_temperature_information() - self.assertEqual( - info, {"CurrentTemperature": 25.0, "TargetTemperature": 37.0, "TemperatureControl": True} - ) - self.mock_sila_interface.send_command.assert_called_with("GetTemperature") - - async def test_get_current_temperature(self): - self.mock_sila_interface.send_command.return_value = ET.fromstring( - "" - " 25.0" - " 37.0" - " true" - "" - ) - temp = await self.backend.get_current_temperature() - self.assertEqual(temp, 25.0) - - async def test_request_target_temperature(self): - self.mock_sila_interface.send_command.return_value = ET.fromstring( - "" - " 25.0" - " 37.0" - " true" - "" - ) - temp = await self.backend.request_target_temperature() - self.assertEqual(temp, 37.0) - - async def test_is_temperature_control_enabled(self): - self.mock_sila_interface.send_command.return_value = ET.fromstring( - "" - " 25.0" - " 37.0" - " true" - "" - ) - enabled = await self.backend.is_temperature_control_enabled() - self.assertIs(enabled, True) - async def test_open(self): for drawer_id in [1, 2, 3, 4]: with self.subTest(drawer_id=drawer_id): self.mock_sila_interface.send_command.reset_mock() - await self.backend.open(drawer_id) + await self.driver.open(drawer_id) self.mock_sila_interface.send_command.assert_any_call("PrepareForInput", position=drawer_id) self.mock_sila_interface.send_command.assert_any_call("OpenDoor") async def test_open_invalid_id(self): with self.assertRaises(ValueError): - await self.backend.open(5) + await self.driver.open(5) async def test_close(self): for drawer_id in [1, 2, 3, 4]: with self.subTest(drawer_id=drawer_id): self.mock_sila_interface.send_command.reset_mock() - await self.backend.close(drawer_id) + await self.driver.close(drawer_id) self.mock_sila_interface.send_command.assert_any_call( "PrepareForOutput", position=drawer_id ) @@ -120,7 +73,7 @@ async def test_close(self): async def test_close_invalid_id(self): with self.assertRaises(ValueError): - await self.backend.close(5) + await self.driver.close(5) async def test_request_drawer_status(self): self.mock_sila_interface.send_command.return_value = ET.fromstring( @@ -131,7 +84,7 @@ async def test_request_drawer_status(self): " Closed" "" ) - positions = await self.backend.request_drawer_statuses() + positions = await self.driver.request_drawer_statuses() self.assertEqual( positions, { @@ -160,18 +113,18 @@ async def test_request_drawer_status_single(self): " Closed" "" ) - position = await self.backend.request_drawer_status(drawer_id) + position = await self.driver.request_drawer_status(drawer_id) self.assertEqual(position, expected_position) async def test_request_drawer_status_invalid_id(self): with self.assertRaises(ValueError): - await self.backend.request_drawer_status(5) + await self.driver.request_drawer_status(5) async def test_request_co2_flow_status(self): self.mock_sila_interface.send_command.return_value = ET.fromstring( "OK" ) - status = await self.backend.request_co2_flow_status() + status = await self.driver.request_co2_flow_status() self.assertEqual(status, "OK") self.mock_sila_interface.send_command.assert_called_with("GetCO2FlowStatus") @@ -183,7 +136,7 @@ async def test_request_valve_status(self): " Closed" "" ) - status = await self.backend.request_valve_status() + status = await self.driver.request_valve_status() self.assertEqual( status, { @@ -194,44 +147,109 @@ async def test_request_valve_status(self): ) self.mock_sila_interface.send_command.assert_called_with("GetValveStatus") - async def test_set_temperature(self): - await self.backend.set_temperature(30.0) - self.mock_sila_interface.send_command.assert_called_with( - "SetTemperature", targetTemperature=30.0, temperatureControl=True - ) - - async def test_deactivate(self): - await self.backend.deactivate() - self.mock_sila_interface.send_command.assert_called_with( - "SetTemperature", temperatureControl=False - ) - def test_serialize(self): self.mock_sila_interface.machine_ip = "169.254.1.117" self.mock_sila_interface.client_ip = "192.168.1.10" - data = self.backend.serialize() + data = self.driver.serialize() self.assertEqual(data["scila_ip"], "169.254.1.117") self.assertEqual(data["client_ip"], "192.168.1.10") def test_serialize_no_client_ip(self): self.mock_sila_interface.machine_ip = "127.0.0.1" self.mock_sila_interface.client_ip = None - data = self.backend.serialize() + data = self.driver.serialize() self.assertEqual(data["scila_ip"], "127.0.0.1") self.assertIsNone(data["client_ip"]) def test_deserialize(self): data = {"scila_ip": "169.254.1.117", "client_ip": "192.168.1.10"} - SCILABackend.deserialize(data) + SCILADriver.deserialize(data) self.MockInhecoSiLAInterface.assert_called_with( client_ip="192.168.1.10", machine_ip="169.254.1.117" ) def test_deserialize_no_client_ip(self): data = {"scila_ip": "169.254.1.117"} - SCILABackend.deserialize(data) + SCILADriver.deserialize(data) self.MockInhecoSiLAInterface.assert_called_with(client_ip=None, machine_ip="169.254.1.117") +class TestSCILATemperatureBackend(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.patcher = patch("pylabrobot.inheco.scila.scila_backend.InhecoSiLAInterface") + self.MockInhecoSiLAInterface = self.patcher.start() + self.mock_sila_interface = AsyncMock(spec=InhecoSiLAInterface) + self.mock_sila_interface.bound_port = 80 + self.mock_sila_interface.client_ip = "127.0.0.1" + self.MockInhecoSiLAInterface.return_value = self.mock_sila_interface + self.driver = SCILADriver(scila_ip="127.0.0.1") + self.backend = SCILATemperatureBackend(driver=self.driver) + + def tearDown(self): + self.patcher.stop() + + async def test_request_temperature_information(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " 25.0" + " 37.0" + " true" + "" + ) + info = await self.backend.request_temperature_information() + self.assertEqual( + info, {"CurrentTemperature": 25.0, "TargetTemperature": 37.0, "TemperatureControl": True} + ) + self.mock_sila_interface.send_command.assert_called_with("GetTemperature") + + async def test_get_current_temperature(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " 25.0" + " 37.0" + " true" + "" + ) + temp = await self.backend.get_current_temperature() + self.assertEqual(temp, 25.0) + + async def test_request_target_temperature(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " 25.0" + " 37.0" + " true" + "" + ) + temp = await self.backend.request_target_temperature() + self.assertEqual(temp, 37.0) + + async def test_is_temperature_control_enabled(self): + self.mock_sila_interface.send_command.return_value = ET.fromstring( + "" + " 25.0" + " 37.0" + " true" + "" + ) + enabled = await self.backend.is_temperature_control_enabled() + self.assertIs(enabled, True) + + async def test_set_temperature(self): + await self.backend.set_temperature(30.0) + self.mock_sila_interface.send_command.assert_called_with( + "SetTemperature", targetTemperature=30.0, temperatureControl=True + ) + + async def test_deactivate(self): + await self.backend.deactivate() + self.mock_sila_interface.send_command.assert_called_with( + "SetTemperature", temperatureControl=False + ) + + def test_supports_active_cooling(self): + self.assertFalse(self.backend.supports_active_cooling) + + if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/keyence/__init__.py b/pylabrobot/keyence/__init__.py index db64521ca5c..94a30681cd9 100644 --- a/pylabrobot/keyence/__init__.py +++ b/pylabrobot/keyence/__init__.py @@ -1 +1,5 @@ -from .keyence_backend import KeyenceBarcodeScannerBackend +from .keyence_backend import ( + KeyenceBarcodeScannerBarcodeScanningBackend, + KeyenceBarcodeScannerDriver, +) +from .keyence_barcode_scanner import KeyenceBarcodeScanner diff --git a/pylabrobot/keyence/keyence_backend.py b/pylabrobot/keyence/keyence_backend.py index a1c331cece1..82c7eabcfa5 100644 --- a/pylabrobot/keyence/keyence_backend.py +++ b/pylabrobot/keyence/keyence_backend.py @@ -21,16 +21,16 @@ logger = logging.getLogger(__name__) -class KeyenceBarcodeScannerBackend(BarcodeScannerBackend, Driver): +class KeyenceBarcodeScannerDriver(Driver): + """Serial driver for Keyence BL-series barcode scanners. + + Owns the serial connection and provides a generic send_command() method. + """ + default_baudrate = 9600 serial_messaging_encoding = "ascii" - init_timeout = 1.0 # seconds - poll_interval = 0.2 # seconds - def __init__( - self, - port: str, - ): + def __init__(self, port: str): if not HAS_SERIAL: raise RuntimeError( "pyserial is not installed. Install with: pip install pylabrobot[serial]. " @@ -54,14 +54,35 @@ def __init__( async def setup(self): await self.io.setup() - await self.initialize() - async def initialize(self): - """Initialize the Keyence barcode scanner.""" + async def stop(self): + await self.io.stop() + + async def send_command(self, command: str) -> str: + """Send a command to the barcode scanner and return the response. + Keyence uses carriage return \\r as the line ending by default.""" + + await self.io.write((command + "\r").encode(self.serial_messaging_encoding)) + response = await self.io.read() + return response.decode(self.serial_messaging_encoding).strip() + + +class KeyenceBarcodeScannerBarcodeScanningBackend(BarcodeScannerBackend): + """Translates BarcodeScannerBackend interface into Keyence driver commands.""" + + init_timeout = 1.0 # seconds + poll_interval = 0.2 # seconds + + def __init__(self, driver: KeyenceBarcodeScannerDriver): + super().__init__() + self._driver = driver + + async def _on_setup(self): + """Initialize the barcode scanner motor after the driver connects.""" deadline = time.time() + self.init_timeout while time.time() < deadline: - response = await self.send_command("RMOTOR") + response = await self._driver.send_command("RMOTOR") if response.strip() == "MOTORON": logger.info("Barcode scanner motor is ON.") break @@ -73,19 +94,8 @@ async def initialize(self): "Failed to initialize Keyence barcode scanner: Timeout waiting for motor to turn on." ) - async def send_command(self, command: str) -> str: - """Send a command to the barcode scanner and return the response. - Keyence uses carriage return \\r as the line ending by default.""" - - await self.io.write((command + "\r").encode(self.serial_messaging_encoding)) - response = await self.io.read() - return response.decode(self.serial_messaging_encoding).strip() - - async def stop(self): - await self.io.stop() - async def scan_barcode(self) -> Barcode: - data = await self.send_command("LON") + data = await self._driver.send_command("LON") if data.startswith("NG"): raise BarcodeScannerError("Barcode reader is off: cannot read barcode") if data.startswith("ERR99"): diff --git a/pylabrobot/keyence/keyence_barcode_scanner.py b/pylabrobot/keyence/keyence_barcode_scanner.py new file mode 100644 index 00000000000..9318e321bb4 --- /dev/null +++ b/pylabrobot/keyence/keyence_barcode_scanner.py @@ -0,0 +1,20 @@ +from pylabrobot.capabilities.barcode_scanning import BarcodeScanningCapability +from pylabrobot.device import Device + +from .keyence_backend import ( + KeyenceBarcodeScannerBarcodeScanningBackend, + KeyenceBarcodeScannerDriver, +) + + +class KeyenceBarcodeScanner(Device): + """Keyence BL-series barcode scanner (BL-600HA, BL-1300).""" + + def __init__(self, port: str): + driver = KeyenceBarcodeScannerDriver(port=port) + super().__init__(driver=driver) + self._driver: KeyenceBarcodeScannerDriver = driver + self.barcode_scanning = BarcodeScanningCapability( + backend=KeyenceBarcodeScannerBarcodeScanningBackend(driver) + ) + self._capabilities = [self.barcode_scanning] diff --git a/pylabrobot/legacy/barcode_scanners/__init__.py b/pylabrobot/legacy/barcode_scanners/__init__.py index 35ce71629e0..92e0b620988 100644 --- a/pylabrobot/legacy/barcode_scanners/__init__.py +++ b/pylabrobot/legacy/barcode_scanners/__init__.py @@ -1,6 +1,6 @@ """Legacy. Use pylabrobot.capabilities.barcode_scanning instead.""" from pylabrobot.capabilities.barcode_scanning import BarcodeScannerBackend, BarcodeScannerError -from pylabrobot.keyence import KeyenceBarcodeScannerBackend +from pylabrobot.legacy.barcode_scanners.keyence import KeyenceBarcodeScannerBackend from .barcode_scanner import BarcodeScanner diff --git a/pylabrobot/legacy/barcode_scanners/keyence/__init__.py b/pylabrobot/legacy/barcode_scanners/keyence/__init__.py index 862134c0271..bf35d3e64a1 100644 --- a/pylabrobot/legacy/barcode_scanners/keyence/__init__.py +++ b/pylabrobot/legacy/barcode_scanners/keyence/__init__.py @@ -1,3 +1,5 @@ """Legacy. Use pylabrobot.keyence instead.""" -from pylabrobot.keyence import KeyenceBarcodeScannerBackend # noqa: F401 +from pylabrobot.legacy.barcode_scanners.keyence.keyence_backend import ( + KeyenceBarcodeScannerBackend, # noqa: F401 +) diff --git a/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py index 808d59ecca4..2c7384d4bc2 100644 --- a/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py +++ b/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py @@ -1,3 +1,31 @@ -"""Legacy. Use pylabrobot.keyence.KeyenceBarcodeScannerBackend instead.""" +"""Legacy. Use pylabrobot.keyence instead.""" -from pylabrobot.keyence.keyence_backend import KeyenceBarcodeScannerBackend # noqa: F401 +from pylabrobot.keyence.keyence_backend import ( + KeyenceBarcodeScannerBarcodeScanningBackend, + KeyenceBarcodeScannerDriver, +) +from pylabrobot.legacy.barcode_scanners.backend import BarcodeScannerBackend +from pylabrobot.resources.barcode import Barcode + + +class KeyenceBarcodeScannerBackend(BarcodeScannerBackend): + """Legacy wrapper around the new Driver + CapabilityBackend. + + In new code, use KeyenceBarcodeScanner (Device) instead. + """ + + def __init__(self, port: str): + super().__init__() + self._driver = KeyenceBarcodeScannerDriver(port=port) + self._barcode_scanning = KeyenceBarcodeScannerBarcodeScanningBackend(self._driver) + + async def setup(self): + await self._driver.setup() + await self._barcode_scanning._on_setup() + + async def stop(self): + await self._barcode_scanning._on_stop() + await self._driver.stop() + + async def scan_barcode(self) -> Barcode: + return await self._barcode_scanning.scan_barcode() diff --git a/pylabrobot/legacy/centrifuge/vspin_backend.py b/pylabrobot/legacy/centrifuge/vspin_backend.py index bd5dc6346b7..fd82e1b9796 100644 --- a/pylabrobot/legacy/centrifuge/vspin_backend.py +++ b/pylabrobot/legacy/centrifuge/vspin_backend.py @@ -11,54 +11,54 @@ class Access2Backend(LoaderBackend): - """Legacy. Use pylabrobot.agilent.vspin.Access2Backend instead.""" + """Legacy. Use pylabrobot.agilent.vspin.Access2Driver instead.""" def __init__(self, device_id: str, timeout: int = 60): - self._new = _new.Access2Backend(device_id=device_id, timeout=timeout) + self._driver = _new.Access2Driver(device_id=device_id, timeout=timeout) @property def io(self): - return self._new.io + return self._driver.io @io.setter def io(self, value): - self._new.io = value + self._driver.io = value @property def timeout(self): - return self._new.timeout + return self._driver.timeout @timeout.setter def timeout(self, value): - self._new.timeout = value + self._driver.timeout = value async def setup(self): - await self._new.setup() + await self._driver.setup() async def stop(self): - await self._new.stop() + await self._driver.stop() def serialize(self): return {"io": self.io.serialize(), "timeout": self.timeout} async def send_command(self, command: bytes) -> bytes: - return await self._new.send_command(command) + return await self._driver.send_command(command) async def get_status(self) -> bytes: - return await self._new.get_status() + return await self._driver.get_status() async def park(self): - await self._new.park() + await self._driver.park() async def close(self): - await self._new.close() + await self._driver.close() async def open(self): - await self._new.open() + await self._driver.open() async def load(self): try: - await self._new.load() + await self._driver.load() except RuntimeError as e: if "no plate found on stage" in str(e): raise LoaderNoPlateError("no plate found on stage") from e @@ -66,7 +66,7 @@ async def load(self): async def unload(self): try: - await self._new.unload() + await self._driver.unload() except RuntimeError as e: if "no plate found in centrifuge" in str(e): raise LoaderNoPlateError("no plate found in centrifuge") from e @@ -74,91 +74,94 @@ async def unload(self): class VSpinBackend(CentrifugeBackend): - """Legacy. Use pylabrobot.agilent.vspin.VSpinBackend instead.""" + """Legacy. Use pylabrobot.agilent.vspin.VSpinDriver instead.""" def __init__(self, device_id: Optional[str] = None): - self._new = _new.VSpinBackend(device_id=device_id) + self._driver = _new.VSpinDriver(device_id=device_id) + self._centrifuge = _new.VSpinCentrifugeBackend(self._driver) @property def io(self): - return self._new.io + return self._driver.io @io.setter def io(self, value): - self._new.io = value + self._driver.io = value @property def _bucket_1_remainder(self): - return self._new._bucket_1_remainder + return self._centrifuge._bucket_1_remainder @_bucket_1_remainder.setter def _bucket_1_remainder(self, value): - self._new._bucket_1_remainder = value + self._centrifuge._bucket_1_remainder = value @property def bucket_1_remainder(self) -> int: - return self._new.bucket_1_remainder + return self._centrifuge.bucket_1_remainder async def setup(self): - await self._new.setup() + await self._driver.setup() + await self._centrifuge._on_setup() async def stop(self): - await self._new.stop() + await self._centrifuge._on_stop() + await self._driver.stop() async def set_bucket_1_position_to_current(self) -> None: - await self._new.set_bucket_1_position_to_current() + await self._centrifuge.set_bucket_1_position_to_current() async def get_bucket_1_position(self) -> int: - return await self._new.get_bucket_1_position() + return await self._centrifuge.get_bucket_1_position() async def get_position(self) -> int: - return await self._new.get_position() + return await self._driver.get_position() async def get_tachometer(self) -> int: - return await self._new.get_tachometer() + return await self._driver.get_tachometer() async def get_home_position(self) -> int: - return await self._new.get_home_position() + return await self._driver.get_home_position() async def get_bucket_locked(self) -> bool: - return await self._new.get_bucket_locked() + return await self._driver.get_bucket_locked() async def get_door_open(self) -> bool: - return await self._new.get_door_open() + return await self._driver.get_door_open() async def get_door_locked(self) -> bool: - return await self._new.get_door_locked() + return await self._driver.get_door_locked() async def open_door(self): - await self._new.open_door() + await self._centrifuge.open_door() async def close_door(self): - await self._new.close_door() + await self._centrifuge.close_door() async def lock_door(self): - await self._new.lock_door() + await self._centrifuge.lock_door() async def unlock_door(self): - await self._new.unlock_door() + await self._centrifuge.unlock_door() async def lock_bucket(self): - await self._new.lock_bucket() + await self._centrifuge.lock_bucket() async def unlock_bucket(self): - await self._new.unlock_bucket() + await self._centrifuge.unlock_bucket() async def go_to_bucket1(self): - await self._new.go_to_bucket1() + await self._centrifuge.go_to_bucket1() async def go_to_bucket2(self): - await self._new.go_to_bucket2() + await self._centrifuge.go_to_bucket2() async def go_to_position(self, position: int): - await self._new.go_to_position(position) + await self._centrifuge.go_to_position(position) @staticmethod def g_to_rpm(g: float) -> int: - return _new.VSpinBackend.g_to_rpm(g) + return _new.VSpinCentrifugeBackend.g_to_rpm(g) async def spin( self, @@ -167,16 +170,16 @@ async def spin( acceleration: float = 0.8, deceleration: float = 0.8, ) -> None: - await self._new.spin( + await self._centrifuge.spin( g=g, duration=duration, - backend_params=_new.VSpinBackend.SpinParams( + backend_params=_new.VSpinCentrifugeBackend.SpinParams( acceleration=acceleration, deceleration=deceleration ), ) async def configure_and_initialize(self): - await self._new.configure_and_initialize() + await self._driver.configure_and_initialize() # Deprecated alias diff --git a/pylabrobot/legacy/heating_shaking/bioshake_backend.py b/pylabrobot/legacy/heating_shaking/bioshake_backend.py index 911f947e846..08c2f14d698 100644 --- a/pylabrobot/legacy/heating_shaking/bioshake_backend.py +++ b/pylabrobot/legacy/heating_shaking/bioshake_backend.py @@ -1,58 +1,64 @@ -"""Legacy. Use pylabrobot.qinstruments.BioShakeBackend instead.""" +"""Legacy. Use pylabrobot.qinstruments.BioShakeDriver instead.""" from pylabrobot.legacy.heating_shaking.backend import HeaterShakerBackend -from pylabrobot.qinstruments import bioshake +from pylabrobot.qinstruments.bioshake import ( + BioShakeDriver, + BioShakeShakerBackend, + BioShakeTemperatureBackend, +) class BioShake(HeaterShakerBackend): - """Legacy. Use pylabrobot.qinstruments.BioShakeBackend instead.""" + """Legacy. Use pylabrobot.qinstruments.BioShakeDriver instead.""" def __init__(self, port: str, timeout: int = 60): - self._new = bioshake.BioShakeBackend(port=port, timeout=timeout) + self._driver = BioShakeDriver(port=port, timeout=timeout) + self._shaker = BioShakeShakerBackend(self._driver) + self._temp = BioShakeTemperatureBackend(self._driver) @property def supports_active_cooling(self) -> bool: - return self._new.supports_active_cooling + return self._temp.supports_active_cooling @property def supports_locking(self) -> bool: - return self._new.supports_locking + return self._shaker.supports_locking async def setup(self, skip_home: bool = False): - await self._new.setup(skip_home=skip_home) + await self._driver.setup(skip_home=skip_home) async def stop(self): - await self._new.stop() + await self._driver.stop() def serialize(self) -> dict: - return self._new.serialize() + return self._driver.serialize() async def reset(self): - await self._new.reset() + await self._driver.reset() async def home(self): - await self._new.home() + await self._driver.home() async def start_shaking(self, speed: float, acceleration: int = 0): - await self._new.start_shaking(speed=speed, acceleration=acceleration) + await self._shaker.start_shaking(speed=speed, acceleration=acceleration) async def shake(self, speed: float, acceleration: int = 0): - await self._new.start_shaking(speed=speed, acceleration=acceleration) + await self._shaker.start_shaking(speed=speed, acceleration=acceleration) async def stop_shaking(self, deceleration: int = 0): - await self._new.stop_shaking(deceleration=deceleration) + await self._shaker.stop_shaking(deceleration=deceleration) async def lock_plate(self): - await self._new.lock_plate() + await self._shaker.lock_plate() async def unlock_plate(self): - await self._new.unlock_plate() + await self._shaker.unlock_plate() async def set_temperature(self, temperature: float): - await self._new.set_temperature(temperature) + await self._temp.set_temperature(temperature) async def get_current_temperature(self) -> float: - return await self._new.get_current_temperature() + return await self._temp.get_current_temperature() async def deactivate(self): - await self._new.deactivate() + await self._temp.deactivate() diff --git a/pylabrobot/legacy/heating_shaking/hamilton_backend.py b/pylabrobot/legacy/heating_shaking/hamilton_backend.py index d26650a800b..5c96ddf91c4 100644 --- a/pylabrobot/legacy/heating_shaking/hamilton_backend.py +++ b/pylabrobot/legacy/heating_shaking/hamilton_backend.py @@ -12,27 +12,33 @@ class HamiltonHeaterShakerBackend(HeaterShakerBackend): - """Legacy. Use pylabrobot.hamilton.heater_shaker.HamiltonHeaterShakerBackend instead.""" + """Legacy. Use pylabrobot.hamilton.heater_shaker instead.""" def __init__(self, index: int, interface: HamiltonHeaterShakerInterface) -> None: - self._new = hhs_backend.HamiltonHeaterShakerBackend(index=index, interface=interface) + self._driver = hhs_backend.HamiltonHeaterShakerDriver(index=index, interface=interface) + self._shaker = hhs_backend.HamiltonHeaterShakerShakerBackend(self._driver) + self._temp = hhs_backend.HamiltonHeaterShakerTemperatureBackend(self._driver) @property def supports_active_cooling(self) -> bool: - return self._new.supports_active_cooling + return self._temp.supports_active_cooling @property def supports_locking(self) -> bool: - return self._new.supports_locking + return self._shaker.supports_locking async def setup(self): - await self._new.setup() + await self._driver.setup() + await self._shaker._on_setup() + await self._temp._on_setup() async def stop(self): - await self._new.stop() + await self._temp._on_stop() + await self._shaker._on_stop() + await self._driver.stop() def serialize(self) -> dict: - return self._new.serialize() + return self._driver.serialize() async def start_shaking( self, @@ -41,7 +47,7 @@ async def start_shaking( acceleration: int = 1_000, timeout: Optional[float] = 30, ): - await self._new.start_shaking( + await self._shaker.start_shaking( speed=speed, direction=direction, acceleration=acceleration, timeout=timeout ) @@ -62,28 +68,28 @@ async def shake( ) async def stop_shaking(self): - await self._new.stop_shaking() + await self._shaker.stop_shaking() async def get_is_shaking(self) -> bool: - return await self._new.get_is_shaking() + return await self._shaker.get_is_shaking() async def lock_plate(self): - await self._new.lock_plate() + await self._shaker.lock_plate() async def unlock_plate(self): - await self._new.unlock_plate() + await self._shaker.unlock_plate() async def set_temperature(self, temperature: float): - await self._new.set_temperature(temperature=temperature) + await self._temp.set_temperature(temperature=temperature) async def get_current_temperature(self) -> float: - return await self._new.get_current_temperature() + return await self._temp.get_current_temperature() async def _get_current_temperature(self) -> Dict[str, float]: - return await self._new._get_current_temperature() + return await self._temp._get_current_temperature() async def get_edge_temperature(self) -> float: - return await self._new.get_edge_temperature() + return await self._temp.get_edge_temperature() async def deactivate(self): - await self._new.deactivate() + await self._temp.deactivate() diff --git a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py index 076d5f1be09..72231ea4101 100644 --- a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py +++ b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py @@ -1,4 +1,4 @@ -"""Legacy. Use pylabrobot.molecular_devices.PicoBackend instead.""" +"""Legacy. Use pylabrobot.molecular_devices.PicoDriver instead.""" from typing import Dict, List, Optional @@ -20,7 +20,7 @@ ImagingResult, Objective, ) -from pylabrobot.molecular_devices.imageXpress.pico.backend import PicoBackend +from pylabrobot.molecular_devices.imageXpress.pico.backend import PicoDriver, PicoMicroscopyBackend from pylabrobot.resources.plate import Plate @@ -41,7 +41,7 @@ def _new_to_legacy_imaging_result(result: NewImagingResult) -> ImagingResult: class ExperimentalPicoBackend(ImagerBackend): - """Legacy. Use pylabrobot.molecular_devices.PicoBackend instead.""" + """Legacy. Use pylabrobot.molecular_devices.PicoDriver instead.""" def __init__( self, @@ -56,50 +56,55 @@ def __init__( new_filter_cubes = { pos: _legacy_to_new_imaging_mode(mode) for pos, mode in (filter_cubes or {}).items() } - self._new = PicoBackend( + self._driver = PicoDriver( host=host, port=port, lock_timeout=lock_timeout, + ) + self._microscopy = PicoMicroscopyBackend( + self._driver, objectives=new_objectives, filter_cubes=new_filter_cubes, ) @property def door_open(self) -> bool: - return self._new.door_open + return self._driver.door_open async def setup(self) -> None: - await self._new.setup() + await self._driver.setup() + await self._microscopy._on_setup() async def stop(self) -> None: - await self._new.stop() + await self._microscopy._on_stop() + await self._driver.stop() async def get_configuration(self) -> dict: - return await self._new.get_configuration() + return await self._driver.get_configuration() async def open_door(self) -> None: - await self._new.open_door() + await self._driver.open_door() async def close_door(self) -> None: - await self._new.close_door() + await self._driver.close_door() async def enter_objective_maintenance(self, position: int) -> None: - await self._new.enter_objective_maintenance(position) + await self._microscopy.enter_objective_maintenance(position) async def exit_objective_maintenance(self) -> None: - await self._new.exit_objective_maintenance() + await self._microscopy.exit_objective_maintenance() async def get_available_objectives(self, position: int) -> List[dict]: - return await self._new.get_available_objectives(position) + return await self._microscopy.get_available_objectives(position) async def get_available_filter_cubes(self) -> List[dict]: - return await self._new.get_available_filter_cubes() + return await self._microscopy.get_available_filter_cubes() async def change_objective(self, position: int, objective_id: str) -> None: - await self._new.change_objective(position, objective_id) + await self._microscopy.change_objective(position, objective_id) async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: - await self._new.change_filter_cube(position, filter_cube_id) + await self._microscopy.change_filter_cube(position, filter_cube_id) async def capture( self, @@ -112,7 +117,7 @@ async def capture( gain: Gain, plate: Plate, ) -> ImagingResult: - result = await self._new.capture( + result = await self._microscopy.capture( row=row, column=column, mode=_legacy_to_new_imaging_mode(mode), diff --git a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py index fe075cbc399..228c304e202 100644 --- a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py +++ b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py @@ -1,4 +1,4 @@ -"""Tests for PicoBackend. +"""Tests for PicoDriver. Focus: verify the gRPC commands generated and responses decoded for each high-level method. The mock channel records every (path, request, metadata) @@ -37,7 +37,7 @@ _LOCK_SVC, _OBJ_SVC, _SNAP_SVC, - PicoBackend, + PicoDriver, _decode_intermediate_response, _extract_image_buffer, _get_image_info, @@ -180,9 +180,9 @@ def _make_backend( objectives=None, filter_cubes=None, lock_timeout=3600, -) -> Tuple[PicoBackend, _MockChannel]: - """Create a PicoBackend with a mock channel, bypassing setup().""" - backend = PicoBackend( +) -> Tuple[PicoDriver, _MockChannel]: + """Create a PicoDriver with a mock channel, bypassing setup().""" + backend = PicoDriver( host="127.0.0.1", port=8091, lock_timeout=lock_timeout, @@ -223,7 +223,7 @@ def _unwrap_sila_string(data: bytes) -> str: class TestSetup(unittest.IsolatedAsyncioTestCase): async def test_setup_sends_correct_sequence(self): """setup() with no objectives/filter_cubes: unlock stale, lock, query hardware.""" - backend = PicoBackend(host="127.0.0.1", lock_timeout=120) + backend = PicoDriver(host="127.0.0.1", lock_timeout=120) channel = _MockChannel() channel.set_response(f"/{_LOCK_SVC}/UnlockServer", b"") @@ -254,7 +254,7 @@ async def test_setup_sends_correct_sequence(self): async def test_setup_configures_objectives_and_filter_cubes(self): """When objectives/filter_cubes are specified, setup() calls ChangeHardware.""" - backend = PicoBackend( + backend = PicoDriver( host="127.0.0.1", objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.DAPI}, diff --git a/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py b/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py index be22976eb8d..5e2795746e9 100644 --- a/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py +++ b/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py @@ -1,26 +1,27 @@ -"""Legacy. Use pylabrobot.hamilton.hepa_fan.HamiltonHepaFanBackend instead.""" +"""Legacy. Use pylabrobot.hamilton.only_fans.HamiltonHepaFan instead.""" -from pylabrobot.hamilton.only_fans import backend as hepa_fan_backend +from pylabrobot.hamilton.only_fans.backend import HamiltonHepaFanDriver, HamiltonHepaFanFanBackend from pylabrobot.legacy.only_fans.backend import FanBackend class HamiltonHepaFanBackend(FanBackend): - """Legacy. Use pylabrobot.hamilton.hepa_fan.HamiltonHepaFanBackend instead.""" + """Legacy. Use pylabrobot.hamilton.only_fans.HamiltonHepaFan instead.""" def __init__(self, device_id=None): - self._new = hepa_fan_backend.HamiltonHepaFanBackend(device_id=device_id) + self._driver = HamiltonHepaFanDriver(device_id=device_id) + self._fan = HamiltonHepaFanFanBackend(self._driver) async def setup(self) -> None: - await self._new.setup() + await self._driver.setup() async def turn_on(self, intensity: int) -> None: - await self._new.turn_on(intensity=intensity) + await self._fan.turn_on(intensity=intensity) async def turn_off(self) -> None: - await self._new.turn_off() + await self._fan.turn_off() async def stop(self) -> None: - await self._new.stop() + await self._driver.stop() class HamiltonHepaFan: diff --git a/pylabrobot/legacy/peeling/xpeel_backend.py b/pylabrobot/legacy/peeling/xpeel_backend.py index ed198d4fefb..38841cb26e8 100644 --- a/pylabrobot/legacy/peeling/xpeel_backend.py +++ b/pylabrobot/legacy/peeling/xpeel_backend.py @@ -1,69 +1,72 @@ -"""Legacy. Use pylabrobot.azenta.XPeelBackend instead.""" +"""Legacy. Use pylabrobot.azenta.XPeelDriver and XPeelPeelerBackend instead.""" -from pylabrobot.azenta import xpeel +from pylabrobot.azenta.xpeel import XPeelDriver, XPeelPeelerBackend from pylabrobot.legacy.peeling.backend import PeelerBackend class XPeelBackend(PeelerBackend): - """Legacy. Use pylabrobot.azenta.XPeelBackend instead.""" + """Legacy. Use pylabrobot.azenta.XPeelDriver and XPeelPeelerBackend instead.""" def __init__(self, port: str, timeout=None): - self._new = xpeel.XPeelBackend(port=port, timeout=timeout) + self._driver = XPeelDriver(port=port, timeout=timeout) + self._peeler = XPeelPeelerBackend(self._driver) async def setup(self): - await self._new.setup() + await self._driver.setup() + await self._peeler._on_setup() async def stop(self): - await self._new.stop() + await self._peeler._on_stop() + await self._driver.stop() def serialize(self) -> dict: - return self._new.serialize() + return self._driver.serialize() async def peel(self, **kwargs): - params = xpeel.XPeelBackend.PeelParams(**kwargs) if kwargs else None - return await self._new.peel(backend_params=params) + params = XPeelPeelerBackend.PeelParams(**kwargs) if kwargs else None + return await self._peeler.peel(backend_params=params) async def restart(self): - return await self._new.restart() + return await self._peeler.restart() async def reset(self): - return await self._new.reset() + return await self._driver.reset() async def get_status(self): - return await self._new.get_status() + return await self._driver.get_status() async def get_version(self): - return await self._new.get_version() + return await self._driver.get_version() async def seal_check(self): - return await self._new.seal_check() + return await self._driver.seal_check() async def get_tape_remaining(self): - return await self._new.get_tape_remaining() + return await self._driver.get_tape_remaining() async def enable_plate_check(self, enabled=True): - return await self._new.enable_plate_check(enabled=enabled) + return await self._driver.enable_plate_check(enabled=enabled) async def get_seal_sensor_status(self): - return await self._new.get_seal_sensor_status() + return await self._driver.get_seal_sensor_status() async def set_seal_threshold_upper(self, value: int): - return await self._new.set_seal_threshold_upper(value=value) + return await self._driver.set_seal_threshold_upper(value=value) async def set_seal_threshold_lower(self, value: int): - return await self._new.set_seal_threshold_lower(value=value) + return await self._driver.set_seal_threshold_lower(value=value) async def move_conveyor_out(self): - return await self._new.move_conveyor_out() + return await self._driver.move_conveyor_out() async def move_conveyor_in(self): - return await self._new.move_conveyor_in() + return await self._driver.move_conveyor_in() async def move_elevator_down(self): - return await self._new.move_elevator_down() + return await self._driver.move_elevator_down() async def move_elevator_up(self): - return await self._new.move_elevator_up() + return await self._driver.move_elevator_up() async def advance_tape(self): - return await self._new.advance_tape() + return await self._driver.advance_tape() diff --git a/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py b/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py index d6d326db08d..6fbb707d83a 100644 --- a/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py +++ b/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py @@ -3,7 +3,13 @@ import sys from typing import Dict, List, Optional, Tuple -from pylabrobot.bmg_labtech import clariostar +from pylabrobot.bmg_labtech.clariostar import ( + CLARIOstarAbsorbanceBackend, + CLARIOstarAbsorbanceParams, + CLARIOstarDriver, + CLARIOstarFluorescenceBackend, + CLARIOstarLuminescenceBackend, +) from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well @@ -15,30 +21,41 @@ class CLARIOstarBackend(PlateReaderBackend): - """Legacy. Use pylabrobot.bmg_labtech.CLARIOstarBackend instead.""" + """Legacy. Use pylabrobot.bmg_labtech.CLARIOstar instead.""" def __init__(self, device_id: Optional[str] = None): - self._new = clariostar.CLARIOstarBackend(device_id=device_id) + self._driver = CLARIOstarDriver(device_id=device_id) + self._absorbance = CLARIOstarAbsorbanceBackend(self._driver) + self._luminescence = CLARIOstarLuminescenceBackend(self._driver) + self._fluorescence = CLARIOstarFluorescenceBackend(self._driver) async def setup(self): - await self._new.setup() + await self._driver.setup() + await self._absorbance._on_setup() + await self._luminescence._on_setup() + await self._fluorescence._on_setup() async def stop(self): - await self._new.stop() + await self._fluorescence._on_stop() + await self._luminescence._on_stop() + await self._absorbance._on_stop() + await self._driver.stop() def serialize(self) -> dict: - return self._new.serialize() + return self._driver.serialize() async def open(self): - await self._new.open() + await self._driver.open() async def close(self, plate: Optional[Plate] = None): - await self._new.close() + await self._driver.close() async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float = 13 ) -> List[Dict]: - results = await self._new.read_luminescence(plate=plate, wells=wells, focal_height=focal_height) + results = await self._luminescence.read_luminescence( + plate=plate, wells=wells, focal_height=focal_height + ) return [ { "data": r.data, @@ -55,10 +72,8 @@ async def read_absorbance( wavelength: int, report: Literal["OD", "transmittance"] = "OD", ) -> List[Dict]: - from pylabrobot.bmg_labtech.clariostar import CLARIOstarBackend - - params = CLARIOstarBackend.AbsorbanceParams(report=report) - results = await self._new.read_absorbance( + params = CLARIOstarAbsorbanceParams(report=report) + results = await self._absorbance.read_absorbance( plate=plate, wells=wells, wavelength=wavelength, backend_params=params ) return [ diff --git a/pylabrobot/legacy/plate_reading/molecular_devices/backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py index 7ec7777490f..6b9f6a2c179 100644 --- a/pylabrobot/legacy/plate_reading/molecular_devices/backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py @@ -21,7 +21,15 @@ ShakeSettings, SpectrumSettings, ) -from pylabrobot.molecular_devices.spectramax.spectramax_m5 import SpectraMaxM5Backend +from pylabrobot.molecular_devices.spectramax.backend import ( + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, + MolecularDevicesTemperatureBackend, +) +from pylabrobot.molecular_devices.spectramax.spectramax_m5 import ( + SpectraMaxM5FluorescenceBackend, + SpectraMaxM5LuminescenceBackend, +) from pylabrobot.resources.plate import Plate @@ -33,64 +41,45 @@ class MolecularDevicesBackend(PlateReaderBackend): """ def __init__(self, port: str) -> None: - self._new: SpectraMaxM5Backend = self._make_new_backend(port) - - # Bridge internal methods so test mocks on self.* intercept calls from _new. - self._real_send_command = self._new.send_command - self._real_read_now = self._new._read_now - self._real_wait_for_idle = self._new._wait_for_idle - self._real_transfer_data = self._new._transfer_data - - async def _sc(*a, **kw): - return await self.send_command(*a, **kw) - - async def _rn(): - return await self._read_now() - - async def _wfi(**kw): - return await self._wait_for_idle(**kw) - - async def _td(*a, **kw): - return await self._transfer_data(*a, **kw) - - self._new.send_command = _sc - self._new._read_now = _rn - self._new._wait_for_idle = _wfi - self._new._transfer_data = _td + self._driver = self._make_driver(port) + self._absorbance = MolecularDevicesAbsorbanceBackend(self._driver) + self._temperature = MolecularDevicesTemperatureBackend(self._driver) + self._fluorescence = SpectraMaxM5FluorescenceBackend(self._driver) + self._luminescence = SpectraMaxM5LuminescenceBackend(self._driver) - def _make_new_backend(self, port: str): - return SpectraMaxM5Backend(port=port) + def _make_driver(self, port: str): + return MolecularDevicesDriver(port=port) # -- PlateReaderBackend / MachineBackend interface ----------------------- async def setup(self) -> None: - await self._new.setup() + await self._driver.setup() async def stop(self) -> None: - await self._new.stop() + await self._driver.stop() async def open(self) -> None: - await self._new.open() + await self._driver.open() async def close(self, plate=None) -> None: - await self._new.close(plate=plate) + await self._driver.close() async def send_command(self, *args, **kwargs): - return await self._real_send_command(*args, **kwargs) + return await self._driver.send_command(*args, **kwargs) def serialize(self) -> dict: - return dict(self._new.serialize()) + return dict(self._driver.serialize()) # -- Bridged internals (must be explicit for class-level @patch) --------- async def _read_now(self): - return await self._real_read_now() + return await self._absorbance._read_now() async def _wait_for_idle(self, **kwargs): - return await self._real_wait_for_idle(**kwargs) + return await self._driver.wait_for_idle(**kwargs) async def _transfer_data(self, *args, **kwargs): - return await self._real_transfer_data(*args, **kwargs) + return await self._absorbance._transfer_data(*args, **kwargs) # -- Legacy read methods (delegate to _new, convert results) ------------- @@ -132,7 +121,7 @@ async def read_absorbance( # type: ignore[override] settling_time=settling_time, timeout=timeout, ) - results = await self._new.read_absorbance( + results = await self._absorbance.read_absorbance( plate=plate, wells=[], wavelength=wavelength, @@ -168,7 +157,7 @@ async def read_fluorescence( # type: ignore[override] settling_time: int = 0, timeout: int = 600, ) -> List[Dict]: - params = SpectraMaxM5Backend.FluorescenceParams( + params = SpectraMaxM5FluorescenceBackend.FluorescenceParams( excitation_wavelengths=excitation_wavelengths, emission_wavelengths=emission_wavelengths, cutoff_filters=cutoff_filters, @@ -186,7 +175,7 @@ async def read_fluorescence( # type: ignore[override] settling_time=settling_time, timeout=timeout, ) - results = await self._new.read_fluorescence( + results = await self._fluorescence.read_fluorescence( plate=plate, wells=[], excitation_wavelength=excitation_wavelengths[0], @@ -223,7 +212,7 @@ async def read_luminescence( # type: ignore[override] settling_time: int = 0, timeout: int = 600, ) -> List[Dict]: - params = SpectraMaxM5Backend.LuminescenceParams( + params = SpectraMaxM5LuminescenceBackend.LuminescenceParams( emission_wavelengths=emission_wavelengths, read_type=read_type, read_order=read_order, @@ -239,7 +228,7 @@ async def read_luminescence( # type: ignore[override] settling_time=settling_time, timeout=timeout, ) - results = await self._new.read_luminescence( + results = await self._luminescence.read_luminescence( plate=plate, wells=[], focal_height=0, @@ -267,7 +256,7 @@ async def read_fluorescence_polarization( settling_time: int = 0, timeout: int = 600, ) -> List[Dict]: - return await self._new.read_fluorescence_polarization( + return await self._fluorescence.read_fluorescence_polarization( plate=plate, excitation_wavelengths=excitation_wavelengths, emission_wavelengths=emission_wavelengths, @@ -309,7 +298,7 @@ async def read_time_resolved_fluorescence( settling_time: int = 0, timeout: int = 600, ) -> List[Dict]: - return await self._new.read_time_resolved_fluorescence( + return await self._fluorescence.read_time_resolved_fluorescence( plate=plate, excitation_wavelengths=excitation_wavelengths, emission_wavelengths=emission_wavelengths, diff --git a/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py index a3ebf92fb3b..c7ca9068cb8 100644 --- a/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py @@ -12,21 +12,27 @@ ShakeSettings, SpectrumSettings, ) -from pylabrobot.molecular_devices.spectramax.spectramax_384_plus import SpectraMax384PlusBackend +from pylabrobot.molecular_devices.spectramax.backend import MolecularDevicesDriver +from pylabrobot.molecular_devices.spectramax.spectramax_384_plus import ( + SpectraMax384PlusAbsorbanceBackend, +) from pylabrobot.resources.plate import Plate from .backend import MolecularDevicesBackend class MolecularDevicesSpectraMax384PlusBackend(MolecularDevicesBackend): - """Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMax384PlusBackend instead. + """Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMax384Plus instead.""" - Delegates to SpectraMax384PlusBackend (which has its own _set_readtype/_set_nvram/_set_tag - overrides), and raises NotImplementedError for unsupported read modes. - """ + def _make_driver(self, port: str): + return MolecularDevicesDriver( + port=port, human_readable_device_name="Molecular Devices SpectraMax 384 Plus" + ) - def _make_new_backend(self, port: str): - return SpectraMax384PlusBackend(port=port) + def __init__(self, port: str) -> None: + super().__init__(port) + # Override the absorbance backend with the 384-specific one + self._absorbance = SpectraMax384PlusAbsorbanceBackend(self._driver) async def read_fluorescence( # type: ignore[override] self, diff --git a/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_m5_backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_m5_backend.py index 7ae92c95c54..0f78245d02f 100644 --- a/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_m5_backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_m5_backend.py @@ -1,10 +1,10 @@ -"""Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMaxM5Backend instead.""" +"""Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMaxM5 instead.""" from .backend import MolecularDevicesBackend class MolecularDevicesSpectraMaxM5Backend(MolecularDevicesBackend): - """Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMaxM5Backend instead.""" + """Legacy. Use pylabrobot.molecular_devices.spectramax.SpectraMaxM5 instead.""" def __init__(self, port: str) -> None: super().__init__(port) diff --git a/pylabrobot/legacy/scales/mettler_toledo_backend.py b/pylabrobot/legacy/scales/mettler_toledo_backend.py index e13a65132d3..8ed2aa9dc47 100644 --- a/pylabrobot/legacy/scales/mettler_toledo_backend.py +++ b/pylabrobot/legacy/scales/mettler_toledo_backend.py @@ -1,38 +1,46 @@ -"""Legacy. Use pylabrobot.mettler_toledo.MettlerToledoWXS205SDUBackend instead.""" +"""Legacy. Use pylabrobot.mettler_toledo.MettlerToledoWXS205SDUDriver and +MettlerToledoWXS205SDUScaleBackend instead.""" import warnings from typing import List, Literal, Optional, Union from pylabrobot.legacy.scales.scale_backend import ScaleBackend -from pylabrobot.mettler_toledo import mettler_toledo as mt +from pylabrobot.mettler_toledo.mettler_toledo import ( + MettlerToledoError, + MettlerToledoWXS205SDUDriver, + MettlerToledoWXS205SDUScaleBackend, +) -MettlerToledoError = mt.MettlerToledoError +MettlerToledoError = MettlerToledoError MettlerToledoResponse = List[str] class MettlerToledoWXS205SDUBackend(ScaleBackend): - """Legacy. Use pylabrobot.mettler_toledo.MettlerToledoWXS205SDUBackend instead.""" + """Legacy. Use MettlerToledoWXS205SDUDriver + MettlerToledoWXS205SDUScaleBackend instead.""" def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6001): - self._new = mt.MettlerToledoWXS205SDUBackend(port=port, vid=vid, pid=pid) + self._driver = MettlerToledoWXS205SDUDriver(port=port, vid=vid, pid=pid) + self._scale = MettlerToledoWXS205SDUScaleBackend(self._driver) async def setup(self) -> None: - await self._new.setup() + await self._driver.setup() + await self._scale._on_setup() async def stop(self) -> None: - await self._new.stop() + await self._scale._on_stop() + await self._driver.stop() def serialize(self) -> dict: - return self._new.serialize() + return self._driver.serialize() async def zero(self, timeout: Union[Literal["stable"], float, int] = "stable"): - return await self._new.zero(timeout=timeout) + return await self._scale.zero(timeout=timeout) async def tare(self, timeout: Union[Literal["stable"], float, int] = "stable"): - return await self._new.tare(timeout=timeout) + return await self._scale.tare(timeout=timeout) async def read_weight(self, timeout: Union[Literal["stable"], float, int] = "stable") -> float: - return await self._new.read_weight(timeout=timeout) + return await self._scale.read_weight(timeout=timeout) async def get_weight(self, timeout: Union[Literal["stable"], float, int] = "stable") -> float: warnings.warn( @@ -40,31 +48,31 @@ async def get_weight(self, timeout: Union[Literal["stable"], float, int] = "stab DeprecationWarning, stacklevel=2, ) - return await self._new.read_weight(timeout=timeout) + return await self._scale.read_weight(timeout=timeout) async def send_command(self, command: str, timeout: int = 60): - return await self._new.send_command(command=command, timeout=timeout) + return await self._driver.send_command(command=command, timeout=timeout) async def request_serial_number(self) -> str: - return await self._new.request_serial_number() + return await self._scale.request_serial_number() async def request_tare_weight(self) -> float: - return await self._new.request_tare_weight() + return await self._scale.request_tare_weight() async def read_stable_weight(self) -> float: - return await self._new.read_stable_weight() + return await self._scale.read_stable_weight() async def read_dynamic_weight(self, timeout: float) -> float: - return await self._new.read_dynamic_weight(timeout=timeout) + return await self._scale.read_dynamic_weight(timeout=timeout) async def read_weight_value_immediately(self) -> float: - return await self._new.read_weight_value_immediately() + return await self._scale.read_weight_value_immediately() async def set_display_text(self, text: str): - return await self._new.set_display_text(text=text) + return await self._driver.set_display_text(text=text) async def set_weight_display(self): - return await self._new.set_weight_display() + return await self._driver.set_weight_display() # Deprecated aliases @@ -74,7 +82,7 @@ async def get_serial_number(self) -> str: DeprecationWarning, stacklevel=2, ) - return await self._new.request_serial_number() + return await self._scale.request_serial_number() async def get_tare_weight(self) -> float: warnings.warn( @@ -82,7 +90,7 @@ async def get_tare_weight(self) -> float: DeprecationWarning, stacklevel=2, ) - return await self._new.request_tare_weight() + return await self._scale.request_tare_weight() async def get_stable_weight(self) -> float: warnings.warn( @@ -90,7 +98,7 @@ async def get_stable_weight(self) -> float: DeprecationWarning, stacklevel=2, ) - return await self._new.read_stable_weight() + return await self._scale.read_stable_weight() async def get_dynamic_weight(self, timeout: float) -> float: warnings.warn( @@ -98,7 +106,7 @@ async def get_dynamic_weight(self, timeout: float) -> float: DeprecationWarning, stacklevel=2, ) - return await self._new.read_dynamic_weight(timeout=timeout) + return await self._scale.read_dynamic_weight(timeout=timeout) async def get_weight_value_immediately(self) -> float: warnings.warn( @@ -106,7 +114,7 @@ async def get_weight_value_immediately(self) -> float: DeprecationWarning, stacklevel=2, ) - return await self._new.read_weight_value_immediately() + return await self._scale.read_weight_value_immediately() class MettlerToledoWXS205SDU: diff --git a/pylabrobot/legacy/sealing/a4s_backend.py b/pylabrobot/legacy/sealing/a4s_backend.py index e4073efb0eb..b7b61b374a1 100644 --- a/pylabrobot/legacy/sealing/a4s_backend.py +++ b/pylabrobot/legacy/sealing/a4s_backend.py @@ -1,50 +1,56 @@ -"""Legacy. Use pylabrobot.azenta.A4SBackend instead.""" +"""Legacy. Use pylabrobot.azenta.A4SDriver / A4SSealerBackend / A4STemperatureBackend instead.""" -from pylabrobot.azenta import a4s +from pylabrobot.azenta.a4s import A4SDriver, A4SSealerBackend, A4STemperatureBackend from pylabrobot.legacy.sealing.backend import SealerBackend class A4SBackend(SealerBackend): - """Legacy. Use pylabrobot.azenta.A4SBackend instead.""" + """Legacy. Use pylabrobot.azenta.A4SDriver / A4SSealerBackend / A4STemperatureBackend instead.""" def __init__(self, port: str, timeout: int = 20): - self._new = a4s.A4SBackend(port=port, timeout=timeout) + self._driver = A4SDriver(port=port, timeout=timeout) + self._sealer = A4SSealerBackend(self._driver) + self._temperature = A4STemperatureBackend(self._driver) async def setup(self): - await self._new.setup() + await self._driver.setup() + await self._sealer._on_setup() + await self._temperature._on_setup() async def stop(self): - await self._new.stop() + await self._temperature._on_stop() + await self._sealer._on_stop() + await self._driver.stop() def serialize(self) -> dict: - return self._new.serialize() + return self._driver.serialize() async def seal(self, temperature: int, duration: float): - await self._new.seal(temperature=temperature, duration=duration) + await self._sealer.seal(temperature=temperature, duration=duration) async def open(self): - return await self._new.open() + return await self._sealer.open() async def close(self): - return await self._new.close() + return await self._sealer.close() async def set_temperature(self, temperature: float): - await self._new.set_temperature(temperature=temperature) + await self._temperature.set_temperature(temperature=temperature) async def get_temperature(self) -> float: - return await self._new.get_current_temperature() + return await self._temperature.get_current_temperature() async def set_heater(self, on: bool): - await self._new.set_heater(on=on) + await self._driver.set_heater(on=on) async def system_reset(self): - await self._new.system_reset() + await self._driver.system_reset() async def set_time(self, seconds: float): - await self._new.set_time(seconds=seconds) + await self._driver.set_time(seconds=seconds) async def get_remaining_time(self) -> int: - return await self._new.get_remaining_time() + return await self._driver.get_remaining_time() async def get_status(self): - return await self._new.get_status() + return await self._driver.get_status() diff --git a/pylabrobot/legacy/storage/inheco/scila/scila_backend.py b/pylabrobot/legacy/storage/inheco/scila/scila_backend.py index dd80c5d8294..127ebd147d7 100644 --- a/pylabrobot/legacy/storage/inheco/scila/scila_backend.py +++ b/pylabrobot/legacy/storage/inheco/scila/scila_backend.py @@ -1,73 +1,76 @@ -"""Legacy. Use pylabrobot.inheco.scila.SCILABackend instead.""" +"""Legacy. Use pylabrobot.inheco.scila.SCILADriver and SCILATemperatureBackend instead.""" from typing import Any, Dict, Literal, Optional -from pylabrobot.inheco.scila import scila_backend as new_scila +from pylabrobot.inheco.scila.scila_backend import SCILADriver, SCILATemperatureBackend from pylabrobot.legacy.machines.backend import MachineBackend DrawerStatus = Literal["Opened", "Closed"] class SCILABackend(MachineBackend): - """Legacy. Use pylabrobot.inheco.scila.SCILABackend instead.""" + """Legacy. Use pylabrobot.inheco.scila.SCILADriver and SCILATemperatureBackend instead.""" def __init__(self, scila_ip: str, client_ip: Optional[str] = None) -> None: - self._new = new_scila.SCILABackend(scila_ip=scila_ip, client_ip=client_ip) + self._driver = SCILADriver(scila_ip=scila_ip, client_ip=client_ip) + self._temp = SCILATemperatureBackend(driver=self._driver) @property def _sila_interface(self): - return self._new._sila_interface + return self._driver._sila_interface async def setup(self) -> None: - await self._new.setup() + await self._driver.setup() + await self._temp._on_setup() async def stop(self) -> None: - await self._new.stop() + await self._temp._on_stop() + await self._driver.stop() async def request_status(self) -> str: - return await self._new.request_status() + return await self._driver.request_status() async def request_liquid_level(self) -> str: - return await self._new.request_liquid_level() + return await self._driver.request_liquid_level() async def request_temperature_information(self) -> dict[str, Any]: - return await self._new.request_temperature_information() + return await self._temp.request_temperature_information() async def measure_temperature(self) -> float: - return await self._new.get_current_temperature() + return await self._temp.get_current_temperature() async def request_target_temperature(self) -> float: - return await self._new.request_target_temperature() + return await self._temp.request_target_temperature() async def is_temperature_control_enabled(self) -> bool: - return await self._new.is_temperature_control_enabled() + return await self._temp.is_temperature_control_enabled() async def open(self, drawer_id: int) -> None: - await self._new.open(drawer_id=drawer_id) + await self._driver.open(drawer_id=drawer_id) async def close(self, drawer_id: int) -> None: - await self._new.close(drawer_id=drawer_id) + await self._driver.close(drawer_id=drawer_id) async def request_drawer_statuses(self) -> Dict[int, DrawerStatus]: - return await self._new.request_drawer_statuses() + return await self._driver.request_drawer_statuses() async def request_drawer_status(self, drawer_id: int) -> DrawerStatus: - return await self._new.request_drawer_status(drawer_id=drawer_id) + return await self._driver.request_drawer_status(drawer_id=drawer_id) async def request_co2_flow_status(self) -> str: - return await self._new.request_co2_flow_status() + return await self._driver.request_co2_flow_status() async def request_valve_status(self) -> dict[str, str]: - return await self._new.request_valve_status() + return await self._driver.request_valve_status() async def start_temperature_control(self, temperature: float) -> None: - await self._new.set_temperature(temperature=temperature) + await self._temp.set_temperature(temperature=temperature) async def stop_temperature_control(self) -> None: - await self._new.deactivate() + await self._temp.deactivate() def serialize(self) -> dict[str, Any]: - return self._new.serialize() + return self._driver.serialize() @classmethod def deserialize(cls, data: dict[str, Any]) -> "SCILABackend": diff --git a/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py b/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py index 448a5301ea3..521dcb55036 100644 --- a/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py +++ b/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py @@ -23,7 +23,7 @@ async def test_setup(self): await self.backend.setup() self.mock_sila_interface.setup.assert_called_once() self.mock_sila_interface.send_command.assert_any_call( - command="Reset", + "Reset", deviceId="MyController", eventReceiverURI="http://127.0.0.1:80/", simulationMode=False, diff --git a/pylabrobot/legacy/temperature_controlling/opentrons_backend.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py index 79e7f199f8c..2f88e706769 100644 --- a/pylabrobot/legacy/temperature_controlling/opentrons_backend.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py @@ -1,38 +1,42 @@ -"""Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleBackend instead.""" +"""Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleTemperatureBackend instead.""" from pylabrobot.legacy.temperature_controlling.backend import ( TemperatureControllerBackend, ) from pylabrobot.opentrons.temperature_module import ( - OpentronsTemperatureModuleBackend as _NewBackend, + OpentronsTemperatureModuleDriver, + OpentronsTemperatureModuleTemperatureBackend as _NewBackend, ) class OpentronsTemperatureModuleBackend(TemperatureControllerBackend): - """Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleBackend instead.""" + """Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleTemperatureBackend instead.""" @property def supports_active_cooling(self) -> bool: - return self._new.supports_active_cooling + return self._backend.supports_active_cooling def __init__(self, opentrons_id: str): - self._new = _NewBackend(opentrons_id=opentrons_id) + self._driver = OpentronsTemperatureModuleDriver(opentrons_id=opentrons_id) + self._backend = _NewBackend(driver=self._driver) self.opentrons_id = opentrons_id async def setup(self): - await self._new.setup() + await self._driver.setup() + await self._backend._on_setup() async def stop(self): - await self._new.stop() + await self._backend._on_stop() + await self._driver.stop() def serialize(self) -> dict: - return self._new.serialize() + return self._driver.serialize() async def set_temperature(self, temperature: float): - await self._new.set_temperature(temperature) + await self._backend.set_temperature(temperature) async def deactivate(self): - await self._new.deactivate() + await self._backend.deactivate() async def get_current_temperature(self) -> float: - return await self._new.get_current_temperature() + return await self._backend.get_current_temperature() diff --git a/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py index 760b9d3ce38..0bb2043fe4f 100644 --- a/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py @@ -1,38 +1,42 @@ -"""Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleUSBBackend instead.""" +"""Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleUSBTemperatureBackend instead.""" from pylabrobot.legacy.temperature_controlling.backend import ( TemperatureControllerBackend, ) from pylabrobot.opentrons.temperature_module import ( - OpentronsTemperatureModuleUSBBackend as _NewBackend, + OpentronsTemperatureModuleUSBDriver, + OpentronsTemperatureModuleUSBTemperatureBackend as _NewBackend, ) class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend): - """Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleUSBBackend instead.""" + """Legacy. Use pylabrobot.opentrons.OpentronsTemperatureModuleUSBTemperatureBackend instead.""" @property def supports_active_cooling(self) -> bool: - return self._new.supports_active_cooling + return self._backend.supports_active_cooling def __init__(self, port: str): - self._new = _NewBackend(port=port) + self._driver = OpentronsTemperatureModuleUSBDriver(port=port) + self._backend = _NewBackend(driver=self._driver) self.port = port async def setup(self): - await self._new.setup() + await self._driver.setup() + await self._backend._on_setup() async def stop(self): - await self._new.stop() + await self._backend._on_stop() + await self._driver.stop() def serialize(self) -> dict: - return self._new.serialize() + return self._driver.serialize() async def set_temperature(self, temperature: float): - await self._new.set_temperature(temperature) + await self._backend.set_temperature(temperature) async def deactivate(self): - await self._new.deactivate() + await self._backend.deactivate() async def get_current_temperature(self) -> float: - return await self._new.get_current_temperature() + return await self._backend.get_current_temperature() diff --git a/pylabrobot/legacy/tilting/__init__.py b/pylabrobot/legacy/tilting/__init__.py index 9f7be386bca..70e4e3ed688 100644 --- a/pylabrobot/legacy/tilting/__init__.py +++ b/pylabrobot/legacy/tilting/__init__.py @@ -1,6 +1,6 @@ """Legacy. Use pylabrobot.capabilities.tilting and pylabrobot.hamilton.tilt_module instead.""" from .hamilton import HamiltonTiltModule -from .hamilton_backend import HamiltonTiltModuleBackend +from .hamilton_backend import HamiltonTiltModuleDriver, HamiltonTiltModuleTilterBackend from .tilter import Tilter from .tilter_backend import TilterBackend diff --git a/pylabrobot/legacy/tilting/hamilton_backend.py b/pylabrobot/legacy/tilting/hamilton_backend.py index d046ec8823b..ab46f891605 100644 --- a/pylabrobot/legacy/tilting/hamilton_backend.py +++ b/pylabrobot/legacy/tilting/hamilton_backend.py @@ -2,6 +2,7 @@ from pylabrobot.capabilities.tilting.backend import TiltModuleError # noqa: F401 from pylabrobot.hamilton.tilt_module.backend import ( # noqa: F401 - HamiltonTiltModuleBackend, - HamiltonTiltModuleChatterboxBackend, + HamiltonTiltModuleChatterboxTilterBackend, + HamiltonTiltModuleDriver, + HamiltonTiltModuleTilterBackend, ) diff --git a/pylabrobot/mettler_toledo/__init__.py b/pylabrobot/mettler_toledo/__init__.py index 9b894ec6afa..3a05701449c 100644 --- a/pylabrobot/mettler_toledo/__init__.py +++ b/pylabrobot/mettler_toledo/__init__.py @@ -1 +1,5 @@ -from .mettler_toledo import MettlerToledoError, MettlerToledoWXS205SDUBackend +from .mettler_toledo import ( + MettlerToledoError, + MettlerToledoWXS205SDUDriver, + MettlerToledoWXS205SDUScaleBackend, +) diff --git a/pylabrobot/mettler_toledo/mettler_toledo.py b/pylabrobot/mettler_toledo/mettler_toledo.py index 6094d105287..53c209c9b73 100644 --- a/pylabrobot/mettler_toledo/mettler_toledo.py +++ b/pylabrobot/mettler_toledo/mettler_toledo.py @@ -143,10 +143,11 @@ def adjustment_needed(from_terminal: bool) -> "MettlerToledoError": MettlerToledoResponse = List[str] -class MettlerToledoWXS205SDUBackend(ScaleBackend, Driver): - """Backend for the Mettler Toledo WXS205SDU scale. +class MettlerToledoWXS205SDUDriver(Driver): + """Driver for the Mettler Toledo WXS205SDU scale. - This scale is used by Hamilton in the liquid verification kit (LVK). + Owns the serial connection and provides a generic send_command method. + Device-level operations (display) live here. Documentation: https://web.archive.org/web/20240208213802/https://www.mt.com/dam/ product_organizations/industry/apw/generic/11781363_N_MAN_RM_MT-SICS_APW_en.pdf @@ -158,11 +159,8 @@ class MettlerToledoWXS205SDUBackend(ScaleBackend, Driver): command processing or ignores entire commands." """ - # === Constructor === - def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6001): super().__init__() - self.io = Serial( human_readable_device_name="Mettler Toledo WXS205SDU", port=port, @@ -173,17 +171,9 @@ def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6 ) async def setup(self) -> None: - await Driver.setup(self) await self.io.setup() - # set output unit to grams - await self.send_command("M21 0 0") - - # Handshake: parse requested serial number - self.serial_number = await self.request_serial_number() - async def stop(self) -> None: - await Driver.stop(self) await self.io.stop() def serialize(self) -> dict: @@ -277,11 +267,38 @@ async def send_command(self, command: str, timeout: int = 60) -> MettlerToledoRe # mypy doesn't understand this return response # type: ignore + # === Device-level operations === + + async def set_display_text(self, text: str) -> MettlerToledoResponse: + """Set the display text of the scale. Return to the normal weight display with + self.set_weight_display().""" + return await self.send_command(f'D "{text}"') + + async def set_weight_display(self) -> MettlerToledoResponse: + """Return the display to the normal weight display.""" + return await self.send_command("DW") + + +class MettlerToledoWXS205SDUScaleBackend(ScaleBackend): + """Translates ScaleBackend interface into driver commands for the WXS205SDU. + + Protocol encoding (building MT-SICS command strings) lives here. + """ + + def __init__(self, driver: MettlerToledoWXS205SDUDriver): + self._driver = driver + self.serial_number: Optional[str] = None + + async def _on_setup(self) -> None: + """Initialize scale after driver connects: set output unit to grams and read serial.""" + await self._driver.send_command("M21 0 0") + self.serial_number = await self.request_serial_number() + # === Public high-level API === async def request_serial_number(self) -> str: """Get the serial number of the scale. (MEM-READ command)""" - response = await self.send_command("I4") + response = await self._driver.send_command("I4") serial_number = response[2] serial_number = serial_number.replace('"', "") return serial_number @@ -290,18 +307,18 @@ async def request_serial_number(self) -> str: async def zero_immediately(self) -> MettlerToledoResponse: """Zero the scale immediately. (ACTION command)""" - return await self.send_command("ZI") + return await self._driver.send_command("ZI") async def zero_stable(self) -> MettlerToledoResponse: """Zero the scale when the weight is stable. (ACTION command)""" - return await self.send_command("Z") + return await self._driver.send_command("Z") async def zero_timeout(self, timeout: float) -> MettlerToledoResponse: """Zero the scale after a given timeout. (ACTION command)""" # For some reason, this will always return a syntax error (ES), even though it should be allowed # according to the docs. timeout = int(timeout * 1000) - return await self.send_command(f"ZC {timeout}") + return await self._driver.send_command(f"ZC {timeout}") async def zero( self, timeout: Union[Literal["stable"], float, int] = "stable" @@ -332,18 +349,18 @@ async def zero( async def tare_stable(self) -> MettlerToledoResponse: """Tare the scale when the weight is stable. (ACTION command)""" - return await self.send_command("T") + return await self._driver.send_command("T") async def tare_immediately(self) -> MettlerToledoResponse: """Tare the scale immediately. (ACTION command)""" - return await self.send_command("TI") + return await self._driver.send_command("TI") async def tare_timeout(self, timeout: float) -> MettlerToledoResponse: """Tare the scale after a given timeout. (ACTION command)""" # For some reason, this will always return a syntax error (ES), even though it should be allowed # according to the docs. timeout = int(timeout * 1000) # convert to milliseconds - return await self.send_command(f"TC {timeout}") + return await self._driver.send_command(f"TC {timeout}") async def tare( self, timeout: Union[Literal["stable"], float, int] = "stable" @@ -377,7 +394,7 @@ async def request_tare_weight(self) -> float: "Use TA to query the current tare value or preset a known tare value." """ - response = await self.send_command("TA") + response = await self._driver.send_command("TA") tare = float(response[2]) unit = response[3] assert unit == "g" # this is the format we expect @@ -385,7 +402,7 @@ async def request_tare_weight(self) -> float: async def clear_tare(self) -> MettlerToledoResponse: """TAC - Clear tare weight value (MEM-WRITE command)""" - return await self.send_command("TAC") + return await self._driver.send_command("TAC") async def read_stable_weight(self) -> float: """Read a stable weight value from the scale. (MEASUREMENT command) @@ -398,7 +415,7 @@ async def read_stable_weight(self) -> float: doors to achieve a stable weight." """ - response = await self.send_command("S") + response = await self._driver.send_command("S") weight = float(response[2]) unit = response[3] assert unit == "g" # this is the format we expect @@ -414,7 +431,7 @@ async def read_dynamic_weight(self, timeout: float) -> float: timeout = int(timeout * 1000) # convert to milliseconds - response = await self.send_command(f"SC {timeout}") + response = await self._driver.send_command(f"SC {timeout}") weight = float(response[2]) unit = response[3] assert unit == "g" # this is the format we expect @@ -427,7 +444,7 @@ async def read_weight_value_immediately(self) -> float: balance to the connected communication partner via the interface." """ - response = await self.send_command("SI") + response = await self._driver.send_command("SI") weight = float(response[2]) assert response[3] == "g" # this is the format we expect return weight @@ -454,14 +471,3 @@ async def read_weight(self, timeout: Union[Literal["stable"], float, int] = "sta return await self.read_weight_value_immediately() return await self.read_dynamic_weight(timeout) - - # Commands for (optional) display manipulation - - async def set_display_text(self, text: str) -> MettlerToledoResponse: - """Set the display text of the scale. Return to the normal weight display with - self.set_weight_display().""" - return await self.send_command(f'D "{text}"') - - async def set_weight_display(self) -> MettlerToledoResponse: - """Return the display to the normal weight display.""" - return await self.send_command("DW") diff --git a/pylabrobot/molecular_devices/__init__.py b/pylabrobot/molecular_devices/__init__.py index d21b01df731..3c60879b988 100644 --- a/pylabrobot/molecular_devices/__init__.py +++ b/pylabrobot/molecular_devices/__init__.py @@ -1,9 +1,12 @@ -from .imageXpress.pico.backend import PicoBackend +from .imageXpress.pico.backend import PicoDriver, PicoMicroscopyBackend from .imageXpress.pico.pico import Pico from .spectramax import ( - MolecularDevicesBackend, + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, + MolecularDevicesTemperatureBackend, SpectraMax384Plus, - SpectraMax384PlusBackend, + SpectraMax384PlusAbsorbanceBackend, SpectraMaxM5, - SpectraMaxM5Backend, + SpectraMaxM5FluorescenceBackend, + SpectraMaxM5LuminescenceBackend, ) diff --git a/pylabrobot/molecular_devices/imageXpress/pico/__init__.py b/pylabrobot/molecular_devices/imageXpress/pico/__init__.py index 34432c9de79..09f61b106b8 100644 --- a/pylabrobot/molecular_devices/imageXpress/pico/__init__.py +++ b/pylabrobot/molecular_devices/imageXpress/pico/__init__.py @@ -1,2 +1,2 @@ -from .backend import PicoBackend +from .backend import PicoDriver, PicoMicroscopyBackend from .pico import Pico diff --git a/pylabrobot/molecular_devices/imageXpress/pico/backend.py b/pylabrobot/molecular_devices/imageXpress/pico/backend.py index b0970719292..d0ab686052f 100644 --- a/pylabrobot/molecular_devices/imageXpress/pico/backend.py +++ b/pylabrobot/molecular_devices/imageXpress/pico/backend.py @@ -314,17 +314,17 @@ def _buffer_to_ndarray(image_buffer: bytes, width: int, height: int): } -class PicoBackend(MicroscopyBackend, Driver): - """Backend for Molecular Devices ImageXpress Pico automated microscope. +class PicoDriver(Driver): + """gRPC/SiLA 2 driver for the Molecular Devices ImageXpress Pico. - Communicates with the instrument via SiLA 2 over gRPC. + Owns the gRPC channel, lock management, and door control. + Microscopy-specific logic (objectives, filter cubes, imaging) lives in + :class:`PicoMicroscopyBackend`. Args: host: IP address or hostname of the instrument. port: gRPC port (default 8091). lock_timeout: Instrument lock timeout in seconds. - objectives: Mapping from 0-indexed turret position to :class:`Objective`. - filter_cubes: Mapping from 0-indexed filter wheel position to :class:`ImagingMode`. """ def __init__( @@ -332,28 +332,12 @@ def __init__( host: str, port: int = 8091, lock_timeout: int = 3600, - objectives: Optional[Dict[int, Objective]] = None, - filter_cubes: Optional[Dict[int, ImagingMode]] = None, ): super().__init__() self._host = host self._port = port self._lock_timeout = lock_timeout - for pos, obj in (objectives or {}).items(): - if obj not in _OBJECTIVE_MAP: - raise ValueError( - f"Objective {obj} not supported by Pico. Supported: {list(_OBJECTIVE_MAP.keys())}" - ) - for pos, mode in (filter_cubes or {}).items(): - if mode not in _IMAGING_MODE_MAP: - raise ValueError( - f"Imaging mode {mode} not supported by Pico. Supported: {list(_IMAGING_MODE_MAP.keys())}" - ) - - self._objectives: Dict[int, Objective] = objectives or {} - self._filter_cubes: Dict[int, ImagingMode] = filter_cubes or {} - self._channel: Optional["grpc.Channel"] = None self._lock_id: Optional[str] = None self._locked = False @@ -452,23 +436,11 @@ async def _unlock(self) -> None: async def _initialize(self) -> None: await self._call(_INST_SVC, "Initialize", b"", with_lock=True) - async def _get_installed_objectives(self) -> List[dict]: - raw = await self._call(_OBJ_SVC, "Get_InstalledObjectives", b"") - data: dict = json.loads(decode_sila_string_response(raw)) - return list(data.get("objectivesData", [])) - - async def _get_installed_filter_cubes(self) -> List[dict]: - raw = await self._call(_FC_SVC, "Get_InstalledFilterCubes", b"") - data: dict = json.loads(decode_sila_string_response(raw)) - return list(data.get("filterCubesData", [])) - # -- lifecycle -- async def setup(self) -> None: if not HAS_GRPC: - raise RuntimeError( - f"grpcio is required for the PicoBackend. Import error: {_GRPC_IMPORT_ERROR}" - ) + raise RuntimeError(f"grpcio is required for PicoDriver. Import error: {_GRPC_IMPORT_ERROR}") self._channel = grpc.insecure_channel( f"{self._host}:{self._port}", options=[ @@ -484,26 +456,7 @@ async def setup(self) -> None: pass await self._lock() - - installed_obj = await self._get_installed_objectives() - num_obj = len(installed_obj) - for pos, obj in self._objectives.items(): - if pos >= num_obj: - raise ValueError( - f"Objective position {pos} out of range (instrument has {num_obj} positions)" - ) - await self.change_objective(pos, _OBJECTIVE_MAP[obj]) - - installed_fc = await self._get_installed_filter_cubes() - num_fc = len(installed_fc) - for pos, mode in self._filter_cubes.items(): - if pos >= num_fc: - raise ValueError( - f"Filter cube position {pos} out of range (instrument has {num_fc} positions)" - ) - await self.change_filter_cube(pos, _IMAGING_MODE_MAP[mode][1]) - - logger.info("PicoBackend: connected to %s:%d", self._host, self._port) + logger.info("PicoDriver: connected to %s:%d", self._host, self._port) async def stop(self) -> None: if self._channel is not None: @@ -514,12 +467,10 @@ async def stop(self) -> None: logger.warning("PicoBackend: unlock failed during stop: %s", e) self._channel.close() self._channel = None - logger.info("PicoBackend: stopped") - - # -- configuration -- + logger.info("PicoDriver: stopped") async def get_configuration(self) -> dict: - """Query the full instrument configuration (objectives, filter cubes, etc.).""" + """Query the full instrument configuration.""" raw = await self._call(_INST_SVC, "Get_InstrumentConfiguration", b"") data: dict = json.loads(decode_sila_string_response(raw)) return dict(data.get("InstrumentConfiguration", data)) @@ -540,28 +491,81 @@ async def close_door(self) -> None: await self._call(_HW_SVC, "ClosePlateDrawer", b"", True) self._door_open = False - # -- objective maintenance -- - async def enter_objective_maintenance(self, position: int) -> None: - if self._door_open: - raise RuntimeError("Cannot enter objective maintenance while the plate drawer is open.") - params = json.dumps({"Index": position}) - req = length_delimited(1, sila_string(params)) - await self._initialize() - await self._call(_OBJ_SVC, "EnterObjectiveMaintenance", req, True) +class PicoMicroscopyBackend(MicroscopyBackend): + """Translates MicroscopyBackend calls into Pico driver RPCs. - async def exit_objective_maintenance(self) -> None: - await self._call(_OBJ_SVC, "ExitObjectiveMaintenance", b"", True) + Owns objective/filter cube configuration, imaging protocol, and + objective maintenance. Uses the driver for raw gRPC calls. + + Args: + driver: The Pico gRPC driver. + objectives: Mapping from 0-indexed turret position to :class:`Objective`. + filter_cubes: Mapping from 0-indexed filter wheel position to :class:`ImagingMode`. + """ + + def __init__( + self, + driver: PicoDriver, + objectives: Optional[Dict[int, Objective]] = None, + filter_cubes: Optional[Dict[int, ImagingMode]] = None, + ): + self._driver = driver + + for pos, obj in (objectives or {}).items(): + if obj not in _OBJECTIVE_MAP: + raise ValueError( + f"Objective {obj} not supported by Pico. Supported: {list(_OBJECTIVE_MAP.keys())}" + ) + for pos, mode in (filter_cubes or {}).items(): + if mode not in _IMAGING_MODE_MAP: + raise ValueError( + f"Imaging mode {mode} not supported by Pico. Supported: {list(_IMAGING_MODE_MAP.keys())}" + ) + + self._objectives: Dict[int, Objective] = objectives or {} + self._filter_cubes: Dict[int, ImagingMode] = filter_cubes or {} + + async def _on_setup(self): + installed_obj = await self._get_installed_objectives() + num_obj = len(installed_obj) + for pos, obj in self._objectives.items(): + if pos >= num_obj: + raise ValueError( + f"Objective position {pos} out of range (instrument has {num_obj} positions)" + ) + await self.change_objective(pos, _OBJECTIVE_MAP[obj]) + + installed_fc = await self._get_installed_filter_cubes() + num_fc = len(installed_fc) + for pos, mode in self._filter_cubes.items(): + if pos >= num_fc: + raise ValueError( + f"Filter cube position {pos} out of range (instrument has {num_fc} positions)" + ) + await self.change_filter_cube(pos, _IMAGING_MODE_MAP[mode][1]) + + # -- objectives & filter cubes -- + + async def _get_installed_objectives(self) -> List[dict]: + raw = await self._driver._call(_OBJ_SVC, "Get_InstalledObjectives", b"") + data: dict = json.loads(decode_sila_string_response(raw)) + return list(data.get("objectivesData", [])) + + async def _get_installed_filter_cubes(self) -> List[dict]: + raw = await self._driver._call(_FC_SVC, "Get_InstalledFilterCubes", b"") + data: dict = json.loads(decode_sila_string_response(raw)) + return list(data.get("filterCubesData", [])) async def get_available_objectives(self, position: int) -> List[dict]: params = json.dumps({"Index": position}) req = length_delimited(1, sila_string(params)) - raw = await self._call(_OBJ_SVC, "GetAvailableObjectivesForPosition", req, True) + raw = await self._driver._call(_OBJ_SVC, "GetAvailableObjectivesForPosition", req, True) data: dict = json.loads(decode_sila_string_response(raw)) return list(data.get("objectives", data.get("Objectives", []))) async def get_available_filter_cubes(self) -> List[dict]: - raw = await self._call(_FC_SVC, "Get_CompatibleFilterCubes", b"") + raw = await self._driver._call(_FC_SVC, "Get_CompatibleFilterCubes", b"") data: dict = json.loads(decode_sila_string_response(raw)) return list(data.get("filterCubes", data.get("FilterCubes", []))) @@ -575,7 +579,7 @@ async def change_objective(self, position: int, objective_id: str) -> None: ) params = json.dumps({"Id": objective_id, "Index": position}) req = length_delimited(1, sila_string(params)) - await self._call(_OBJ_SVC, "ChangeHardware", req, True) + await self._driver._call(_OBJ_SVC, "ChangeHardware", req, True) async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: available = await self.get_available_filter_cubes() @@ -587,7 +591,18 @@ async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: ) params = json.dumps({"Id": filter_cube_id, "Index": position}) req = length_delimited(1, sila_string(params)) - await self._call(_FC_SVC, "ChangeHardware", req, True) + await self._driver._call(_FC_SVC, "ChangeHardware", req, True) + + async def enter_objective_maintenance(self, position: int) -> None: + if self._driver.door_open: + raise RuntimeError("Cannot enter objective maintenance while the plate drawer is open.") + params = json.dumps({"Index": position}) + req = length_delimited(1, sila_string(params)) + await self._driver._initialize() + await self._driver._call(_OBJ_SVC, "EnterObjectiveMaintenance", req, True) + + async def exit_objective_maintenance(self) -> None: + await self._driver._call(_OBJ_SVC, "ExitObjectiveMaintenance", b"", True) # -- imaging -- @@ -595,10 +610,10 @@ async def _snap_images(self, labware_params: dict, snap_params: dict) -> List[di labware_json = json.dumps(labware_params) snap_json = json.dumps(snap_params) - await self._initialize() + await self._driver._initialize() request = _snap_images_params(labware_json, snap_json) - confirmation_raw = await self._call( + confirmation_raw = await self._driver._call( _SNAP_SVC, "SnapImages", request, with_lock=True, timeout=60.0 ) exec_uuid = decode_command_confirmation(confirmation_raw) @@ -608,7 +623,7 @@ async def _snap_images(self, labware_params: dict, snap_params: dict) -> List[di chunks: Dict[int, Dict[int, bytes]] = defaultdict(dict) checksums: Dict[int, int] = {} - for response_raw in await self._stream( + for response_raw in await self._driver._stream( _SNAP_SVC, "SnapImages_Intermediate", uuid_request, @@ -619,7 +634,9 @@ async def _snap_images(self, labware_params: dict, snap_params: dict) -> List[di chunks[meta["blob_index"]][meta["packet_index"]] = chunk_data checksums[meta["blob_index"]] = meta["blob_checksum"] - await self._call(_SNAP_SVC, "SnapImages_Result", uuid_request, with_lock=True, timeout=60.0) + await self._driver._call( + _SNAP_SVC, "SnapImages_Result", uuid_request, with_lock=True, timeout=60.0 + ) images = [] for blob_idx in sorted(chunks.keys()): @@ -707,7 +724,7 @@ async def capture( snap_params["focusSettings"]["baseZPositionUm"] = base_z_um labware_params = _labware_params_from_plate(plate) - images = await self._snap_images(labware_params, snap_params) + images = await self._driver._snap_images(labware_params, snap_params) result_images: List = [] actual_exposure_us = exposure_us diff --git a/pylabrobot/molecular_devices/imageXpress/pico/pico.py b/pylabrobot/molecular_devices/imageXpress/pico/pico.py index d58ee261539..8f5f0b00a2e 100644 --- a/pylabrobot/molecular_devices/imageXpress/pico/pico.py +++ b/pylabrobot/molecular_devices/imageXpress/pico/pico.py @@ -4,7 +4,7 @@ from pylabrobot.device import Device from pylabrobot.resources import Resource, Rotation -from .backend import PicoBackend +from .backend import PicoDriver, PicoMicroscopyBackend class Pico(Resource, Device): @@ -37,12 +37,10 @@ def __init__( category: Optional[str] = "microscope", model: Optional[str] = "ImageXpress Pico", ): - backend = PicoBackend( + driver = PicoDriver( host=host, port=port, lock_timeout=lock_timeout, - objectives=objectives, - filter_cubes=filter_cubes, ) Resource.__init__( self, @@ -54,8 +52,14 @@ def __init__( category=category, model=model, ) - Device.__init__(self, driver=backend) - self._driver: PicoBackend = backend + Device.__init__(self, driver=driver) + self._driver: PicoDriver = driver - self.microscopy = MicroscopyCapability(backend=backend) + self.microscopy = MicroscopyCapability( + backend=PicoMicroscopyBackend( + driver=driver, + objectives=objectives, + filter_cubes=filter_cubes, + ) + ) self._capabilities = [self.microscopy] diff --git a/pylabrobot/molecular_devices/spectramax/__init__.py b/pylabrobot/molecular_devices/spectramax/__init__.py index dccea07989c..e458bfed5d1 100644 --- a/pylabrobot/molecular_devices/spectramax/__init__.py +++ b/pylabrobot/molecular_devices/spectramax/__init__.py @@ -4,13 +4,15 @@ Calibrate, CarriageSpeed, KineticSettings, - MolecularDevicesBackend, + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, MolecularDevicesError, MolecularDevicesFirmwareError, MolecularDevicesHardwareError, MolecularDevicesMotionError, MolecularDevicesNVRAMError, MolecularDevicesSettings, + MolecularDevicesTemperatureBackend, MolecularDevicesUnrecognizedCommandError, PmtGain, ReadMode, @@ -19,5 +21,9 @@ ShakeSettings, SpectrumSettings, ) -from .spectramax_384_plus import SpectraMax384Plus, SpectraMax384PlusBackend -from .spectramax_m5 import SpectraMaxM5, SpectraMaxM5Backend +from .spectramax_384_plus import SpectraMax384Plus, SpectraMax384PlusAbsorbanceBackend +from .spectramax_m5 import ( + SpectraMaxM5, + SpectraMaxM5FluorescenceBackend, + SpectraMaxM5LuminescenceBackend, +) diff --git a/pylabrobot/molecular_devices/spectramax/backend.py b/pylabrobot/molecular_devices/spectramax/backend.py index 12a6db8f2ba..b03a982a649 100644 --- a/pylabrobot/molecular_devices/spectramax/backend.py +++ b/pylabrobot/molecular_devices/spectramax/backend.py @@ -6,14 +6,14 @@ from enum import Enum from typing import Dict, List, Literal, Optional, Tuple, Union +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_reading.absorbance.backend import AbsorbanceBackend -from pylabrobot.device import Driver from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult from pylabrobot.capabilities.temperature_controlling.backend import TemperatureControllerBackend +from pylabrobot.device import Driver from pylabrobot.io.serial import Serial from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well -from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin logger = logging.getLogger("pylabrobot") @@ -68,6 +68,11 @@ } +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + + class MolecularDevicesError(Exception): """Exceptions raised by a Molecular Devices plate reader.""" @@ -147,6 +152,11 @@ class MolecularDevicesNVRAMError(MolecularDevicesError): MolecularDevicesResponse = List[str] +# --------------------------------------------------------------------------- +# Enums & settings dataclasses +# --------------------------------------------------------------------------- + + class ReadMode(Enum): """The read mode of the plate reader (e.g., Absorbance, Fluorescence).""" @@ -251,12 +261,16 @@ class MolecularDevicesSettings: settling_time: int = 0 -class MolecularDevicesBackend(AbsorbanceBackend, TemperatureControllerBackend, Driver): - """Backend for Molecular Devices plate readers. Supports absorbance reading. +# --------------------------------------------------------------------------- +# Driver — serial I/O and device-level operations +# --------------------------------------------------------------------------- + - Contains all serial protocol code, enums, dataclasses, and exceptions shared - across Molecular Devices SpectraMax instruments. Subclasses add fluorescence, - luminescence, and other capabilities. +class MolecularDevicesDriver(Driver): + """Serial driver for Molecular Devices plate readers. + + Owns the serial connection, command protocol, and device-level operations + (open/close tray, status, error log, shake). """ def __init__( @@ -313,7 +327,6 @@ def _parse_basic_errors(self, response: List[str], command: str) -> None: if not response: raise MolecularDevicesError(f"Command '{command}' failed with empty response.") - # Check for FAIL in the response error_code_msg = response[0] if "FAIL" in response[0] else response[-1] if "FAIL" in error_code_msg: parts = error_code_msg.split("\t") @@ -336,71 +349,77 @@ def _parse_basic_errors(self, response: List[str], command: str) -> None: if "warning" in response[0].lower(): logger.warning("Warning for command '%s': %s", command, response) + # -- device-level operations -- + async def open(self) -> None: + """Open the plate tray.""" await self.send_command("!OPEN") - async def close(self, plate: Optional[Plate] = None) -> None: + async def close(self) -> None: + """Close the plate tray.""" await self.send_command("!CLOSE") async def get_status(self) -> List[str]: + """Get the current device status.""" res = await self.send_command("!STATUS") if len(res) > 1: return res[1].split() raise ValueError(f"Could not parse status from response: {res}") async def read_error_log(self) -> List[str]: + """Read the device error log.""" res = await self.send_command("!ERROR") if len(res) > 1: return res[1].split() raise ValueError(f"Could not parse error log from response: {res}") async def clear_error_log(self) -> None: + """Clear the device error log.""" await self.send_command("!CLEAR ERROR") - async def get_temperature(self) -> Tuple[float, float]: - res = await self.send_command("!TEMP") - if len(res) > 1: - parts = res[1].split() - else: - parts = res[0].replace("OK", "").split() - - if len(parts) >= 2: - return (float(parts[1]), float(parts[0])) # current, set_point - raise ValueError(f"Could not parse temperature from response: {res}") - - @property - def supports_active_cooling(self) -> bool: - return False - - async def get_current_temperature(self) -> float: - current, _ = await self.get_temperature() - return current - - async def set_temperature(self, temperature: float) -> None: - if not (0 <= temperature <= 45): - raise ValueError("Temperature must be between 0 and 45°C.") - await self.send_command(f"!TEMP {temperature}") - - async def deactivate(self) -> None: - await self.send_command("!TEMP 0") - async def get_firmware_version(self) -> List[str]: + """Get the firmware version.""" res = await self.send_command("!OPTION") return res[1].split() async def start_shake(self) -> None: + """Start shaking.""" await self.send_command("!SHAKE NOW") async def stop_shake(self) -> None: + """Stop shaking.""" await self.send_command("!SHAKE STOP") + async def wait_for_idle(self, timeout: int = 600): + """Wait for the plate reader to become idle.""" + start_time = time.time() + while True: + if time.time() - start_time > timeout: + raise TimeoutError("Timeout waiting for plate reader to become idle.") + status = await self.get_status() + if status and status[1] == "IDLE": + break + await asyncio.sleep(1) + + +# --------------------------------------------------------------------------- +# Shared protocol helpers — used by all capability backends +# --------------------------------------------------------------------------- + + +class _MolecularDevicesProtocol: + """Mixin with shared _set_* command builders for Molecular Devices readers. + + Subclasses must have ``self._driver: MolecularDevicesDriver``. + """ + + _driver: MolecularDevicesDriver + async def _read_now(self) -> None: - await self.send_command("!READ") + await self._driver.send_command("!READ") async def _transfer_data(self, settings: MolecularDevicesSettings) -> List[Dict]: - """Transfer data from the plate reader. For kinetic/spectrum reads, this will transfer data - for each reading and combine them into a single collection. - """ + """Transfer data from the plate reader.""" if (settings.read_type == ReadType.KINETIC and settings.kinetic_settings) or ( settings.read_type == ReadType.SPECTRUM and settings.spectrum_settings @@ -414,14 +433,13 @@ async def _transfer_data(self, settings: MolecularDevicesSettings) -> List[Dict] all_reads = [] for _ in range(num_readings): - res = await self.send_command("!TRANSFER") + res = await self._driver.send_command("!TRANSFER") data_str = res[1] read_data = self._parse_data(data_str, settings) all_reads.extend(read_data) return all_reads - # For ENDPOINT - res = await self.send_command("!TRANSFER") + res = await self._driver.send_command("!TRANSFER") data_str = res[1] return self._parse_data(data_str, settings) @@ -429,29 +447,23 @@ def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> List lines = re.split(r"\r\n|\n", data_str.strip()) lines = [line.strip() for line in lines if line.strip()] - # 1. Parse header header_parts = lines[0].split("\t") measurement_time = float(header_parts[0]) temperature = float(header_parts[1]) - # 2. Parse wavelengths line_idx = 1 while line_idx < len(lines): line = lines[line_idx] if line.startswith("L:") and line_idx > 1: - # Data section started break line_idx += 1 data_collection = [] cur_read_wavelengths = [] - # 3. Parse data data_columns: List[List[float]] = [] - # The data section starts at line_idx for i in range(line_idx, len(lines)): line = lines[i] if line.startswith("L:"): - # start of a new data with different wavelength cur_read_wavelengths.append(line.split("\t")[1:]) if i > line_idx and data_columns: data_collection.append(data_columns) @@ -471,7 +483,6 @@ def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> List if data_columns: data_collection.append(data_columns) - # 4. Transpose data to be row-major data_collection_transposed = [] for data_columns in data_collection: data_rows = [] @@ -507,7 +518,7 @@ def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> List return measurements async def _set_clear(self) -> None: - await self.send_command("!CLEAR DATA") + await self._driver.send_command("!CLEAR DATA") async def _set_mode(self, settings: MolecularDevicesSettings) -> None: cmd = f"!MODE {settings.read_type.value}" @@ -519,7 +530,7 @@ async def _set_mode(self, settings: MolecularDevicesSettings) -> None: cmd = "!MODE" scan_type = ss.excitation_emission_type or "SPECTRUM" cmd += f" {scan_type} {ss.start_wavelength} {ss.step} {ss.num_steps}" - await self.send_command(cmd) + await self._driver.send_command(cmd) async def _set_wavelengths(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.ABS: @@ -529,15 +540,15 @@ async def _set_wavelengths(self, settings: MolecularDevicesSettings) -> None: wl_str = " ".join(wl_parts) if settings.path_check: wl_str += " 900 998" - await self.send_command(f"!WAVELENGTH {wl_str}") + await self._driver.send_command(f"!WAVELENGTH {wl_str}") elif settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME): ex_wl_str = " ".join(map(str, settings.excitation_wavelengths)) em_wl_str = " ".join(map(str, settings.emission_wavelengths)) - await self.send_command(f"!EXWAVELENGTH {ex_wl_str}") - await self.send_command(f"!EMWAVELENGTH {em_wl_str}") + await self._driver.send_command(f"!EXWAVELENGTH {ex_wl_str}") + await self._driver.send_command(f"!EMWAVELENGTH {em_wl_str}") elif settings.read_mode == ReadMode.LUM: wl_str = " ".join(map(str, settings.emission_wavelengths)) - await self.send_command(f"!EMWAVELENGTH {wl_str}") + await self._driver.send_command(f"!EMWAVELENGTH {wl_str}") else: raise NotImplementedError(f"{settings.read_mode} not supported") @@ -560,15 +571,15 @@ async def _set_plate_position(self, settings: MolecularDevicesSettings) -> None: x_pos_cmd = f"!XPOS {top_left_well_center.x:.3f} {dx:.3f} {num_cols}" y_pos_cmd = f"!YPOS {size_y - top_left_well_center.y:.3f} {dy:.3f} {num_rows}" - await self.send_command(x_pos_cmd) - await self.send_command(y_pos_cmd) + await self._driver.send_command(x_pos_cmd) + await self._driver.send_command(y_pos_cmd) async def _set_strip(self, settings: MolecularDevicesSettings) -> None: - await self.send_command(f"!STRIP 1 {settings.plate.num_items_x}") + await self._driver.send_command(f"!STRIP 1 {settings.plate.num_items_x}") async def _set_shake(self, settings: MolecularDevicesSettings) -> None: if not settings.shake_settings: - await self.send_command("!SHAKE OFF") + await self._driver.send_command("!SHAKE OFF") return ss = settings.shake_settings shake_mode = "ON" if ss.before_read or ss.between_reads else "OFF" @@ -580,31 +591,33 @@ async def _set_shake(self, settings: MolecularDevicesSettings) -> None: else: between_duration = 0 wait_duration = 0 - await self.send_command(f"!SHAKE {shake_mode}") - await self.send_command(f"!SHAKE {before_duration} {ki} {wait_duration} {between_duration} 0") + await self._driver.send_command(f"!SHAKE {shake_mode}") + await self._driver.send_command( + f"!SHAKE {before_duration} {ki} {wait_duration} {between_duration} 0" + ) async def _set_carriage_speed(self, settings: MolecularDevicesSettings) -> None: - await self.send_command(f"!CSPEED {settings.carriage_speed.value}") + await self._driver.send_command(f"!CSPEED {settings.carriage_speed.value}") async def _set_read_stage(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): stage = "BOT" if settings.read_from_bottom else "TOP" - await self.send_command(f"!READSTAGE {stage}") + await self._driver.send_command(f"!READSTAGE {stage}") async def _set_flashes_per_well(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): - await self.send_command(f"!FPW {settings.flashes_per_well}") + await self._driver.send_command(f"!FPW {settings.flashes_per_well}") async def _set_pmt(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode not in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): return gain = settings.pmt_gain if gain == PmtGain.AUTO: - await self.send_command("!AUTOPMT ON") + await self._driver.send_command("!AUTOPMT ON") else: gain_val = gain.value if isinstance(gain, PmtGain) else gain - await self.send_command("!AUTOPMT OFF") - await self.send_command(f"!PMT {gain_val}") + await self._driver.send_command("!AUTOPMT OFF") + await self._driver.send_command(f"!PMT {gain_val}") async def _set_filter(self, settings: MolecularDevicesSettings) -> None: if ( @@ -612,24 +625,24 @@ async def _set_filter(self, settings: MolecularDevicesSettings) -> None: and settings.cutoff_filters ): cf_str = " ".join(map(str, settings.cutoff_filters)) - await self.send_command("!AUTOFILTER OFF") - await self.send_command(f"!EMFILTER {cf_str}") + await self._driver.send_command("!AUTOFILTER OFF") + await self._driver.send_command(f"!EMFILTER {cf_str}") else: - await self.send_command("!AUTOFILTER ON") + await self._driver.send_command("!AUTOFILTER ON") async def _set_calibrate(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.ABS: - await self.send_command(f"!CALIBRATE {settings.calibrate.value}") + await self._driver.send_command(f"!CALIBRATE {settings.calibrate.value}") else: - await self.send_command(f"!PMTCAL {settings.calibrate.value}") + await self._driver.send_command(f"!PMTCAL {settings.calibrate.value}") async def _set_order(self, settings: MolecularDevicesSettings) -> None: - await self.send_command(f"!ORDER {settings.read_order.value}") + await self._driver.send_command(f"!ORDER {settings.read_order.value}") async def _set_speed(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.ABS: mode = "ON" if settings.speed_read else "OFF" - await self.send_command(f"!SPEED {mode}") + await self._driver.send_command(f"!SPEED {mode}") async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.POLAR: @@ -638,13 +651,13 @@ async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: else: command = "CARCOL" value = settings.settling_time if settings.settling_time > 100 else 100 - await self.send_command(f"!NVRAM {command} {value}") + await self._driver.send_command(f"!NVRAM {command} {value}") async def _set_tag(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.POLAR and settings.read_type == ReadType.KINETIC: - await self.send_command("!TAG ON") + await self._driver.send_command("!TAG ON") else: - await self.send_command("!TAG OFF") + await self._driver.send_command("!TAG OFF") async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: """Set the READTYPE command and the expected number of response fields.""" @@ -668,14 +681,14 @@ async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: else: raise ValueError(f"Unsupported read mode: {settings.read_mode}") - await self.send_command(cmd, num_res_fields=num_res_fields) + await self._driver.send_command(cmd, num_res_fields=num_res_fields) async def _set_integration_time( self, settings: MolecularDevicesSettings, delay_time: int, integration_time: int ) -> None: if settings.read_mode == ReadMode.TIME: - await self.send_command(f"!COUNTTIMEDELAY {delay_time}") - await self.send_command(f"!COUNTTIME {integration_time * 0.001}") + await self._driver.send_command(f"!COUNTTIMEDELAY {delay_time}") + await self._driver.send_command(f"!COUNTTIME {integration_time * 0.001}") def _get_cutoff_filter_index_from_wavelength(self, wavelength: int) -> int: """Converts a wavelength to a cutoff filter index.""" @@ -702,16 +715,17 @@ def _get_cutoff_filter_index_from_wavelength(self, wavelength: int) -> int: return cutoff_filter_index raise ValueError(f"No cutoff filter found for wavelength {wavelength}") - async def _wait_for_idle(self, timeout: int = 600): - """Wait for the plate reader to become idle.""" - start_time = time.time() - while True: - if time.time() - start_time > timeout: - raise TimeoutError("Timeout waiting for plate reader to become idle.") - status = await self.get_status() - if status and status[1] == "IDLE": - break - await asyncio.sleep(1) + +# --------------------------------------------------------------------------- +# Capability backends +# --------------------------------------------------------------------------- + + +class MolecularDevicesAbsorbanceBackend(_MolecularDevicesProtocol, AbsorbanceBackend): + """Translates AbsorbanceBackend interface into Molecular Devices commands.""" + + def __init__(self, driver: MolecularDevicesDriver) -> None: + self._driver = driver @dataclass class AbsorbanceParams(BackendParams): @@ -737,7 +751,7 @@ async def read_absorbance( backend_params: Optional[SerializableMixin] = None, ) -> List[AbsorbanceResult]: if not isinstance(backend_params, self.AbsorbanceParams): - backend_params = MolecularDevicesBackend.AbsorbanceParams() + backend_params = MolecularDevicesAbsorbanceBackend.AbsorbanceParams() wavelengths = ( backend_params.wavelengths if backend_params.wavelengths is not None else [wavelength] @@ -775,7 +789,7 @@ async def read_absorbance( await self._set_readtype(settings) await self._read_now() - await self._wait_for_idle(timeout=backend_params.timeout) + await self._driver.wait_for_idle(timeout=backend_params.timeout) dicts = await self._transfer_data(settings) return [ AbsorbanceResult( @@ -786,3 +800,38 @@ async def read_absorbance( ) for d in dicts ] + + +class MolecularDevicesTemperatureBackend(TemperatureControllerBackend): + """Translates TemperatureControllerBackend interface into Molecular Devices commands.""" + + def __init__(self, driver: MolecularDevicesDriver) -> None: + self._driver = driver + + @property + def supports_active_cooling(self) -> bool: + return False + + async def get_temperature(self) -> Tuple[float, float]: + """Get (current_temp, set_point) from the device.""" + res = await self._driver.send_command("!TEMP") + if len(res) > 1: + parts = res[1].split() + else: + parts = res[0].replace("OK", "").split() + + if len(parts) >= 2: + return (float(parts[1]), float(parts[0])) + raise ValueError(f"Could not parse temperature from response: {res}") + + async def get_current_temperature(self) -> float: + current, _ = await self.get_temperature() + return current + + async def set_temperature(self, temperature: float) -> None: + if not (0 <= temperature <= 45): + raise ValueError("Temperature must be between 0 and 45°C.") + await self._driver.send_command(f"!TEMP {temperature}") + + async def deactivate(self) -> None: + await self._driver.send_command("!TEMP 0") diff --git a/pylabrobot/molecular_devices/spectramax/backend_tests.py b/pylabrobot/molecular_devices/spectramax/backend_tests.py index c55b576a330..b769668972c 100644 --- a/pylabrobot/molecular_devices/spectramax/backend_tests.py +++ b/pylabrobot/molecular_devices/spectramax/backend_tests.py @@ -1,4 +1,3 @@ -import math import unittest from unittest.mock import AsyncMock, MagicMock, call, patch @@ -9,10 +8,9 @@ Calibrate, CarriageSpeed, KineticSettings, - MolecularDevicesBackend, - MolecularDevicesError, + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, MolecularDevicesSettings, - MolecularDevicesUnrecognizedCommandError, PmtGain, ReadMode, ReadOrder, @@ -20,12 +18,18 @@ ShakeSettings, SpectrumSettings, ) -from pylabrobot.molecular_devices.spectramax.spectramax_m5 import SpectraMaxM5Backend +from pylabrobot.molecular_devices.spectramax.spectramax_m5 import ( + SpectraMaxM5FluorescenceBackend, + SpectraMaxM5LuminescenceBackend, +) from pylabrobot.resources.agenbio.plates import AGenBio_96_wellplate_Ub_2200ul class TestMolecularDevicesBackend(unittest.IsolatedAsyncioTestCase): - backend: MolecularDevicesBackend + """Tests for MolecularDevicesAbsorbanceBackend and the protocol mixin.""" + + backend: MolecularDevicesAbsorbanceBackend + driver: MolecularDevicesDriver mock_serial: MagicMock send_command_mock: AsyncMock @@ -37,21 +41,22 @@ def setUp(self): self.mock_serial.readline = AsyncMock(return_value=b"OK>\r\n") with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): - self.backend = MolecularDevicesBackend(port="COM1") - self.backend.io = self.mock_serial - self.send_command_mock = patch.object( - self.backend, "send_command", new_callable=AsyncMock - ).start() + self.driver = MolecularDevicesDriver(port="COM1") + self.driver.io = self.mock_serial + self.backend = MolecularDevicesAbsorbanceBackend(driver=self.driver) + self.send_command_mock = patch.object( + self.driver, "send_command", new_callable=AsyncMock + ).start() self.addCleanup(patch.stopall) async def test_setup_stop(self): with patch.object( - self.backend, "send_command", wraps=self.backend.send_command + self.driver, "send_command", wraps=self.driver.send_command ) as wrapped_send_command: - await self.backend.setup() + await self.driver.setup() self.mock_serial.setup.assert_called_once() wrapped_send_command.assert_called_with("!") - await self.backend.stop() + await self.driver.stop() self.mock_serial.stop.assert_called_once() async def test_set_clear(self): @@ -456,54 +461,54 @@ async def test_set_tag(self): await self.backend._set_tag(settings) self.send_command_mock.assert_called_once_with("!TAG OFF") - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._wait_for_idle", - new_callable=AsyncMock, - ) - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._transfer_data", - new_callable=AsyncMock, - return_value=[{"data": [[0.1]], "wavelength": 500, "temperature": 25.0, "time": 12345.6}], - ) - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._read_now", - new_callable=AsyncMock, - ) - async def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): - plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - results = await self.backend.read_absorbance(plate, plate.get_wells(), 500) - - # Verify typed results - self.assertIsInstance(results, list) - self.assertEqual(len(results), 1) - self.assertIsInstance(results[0], AbsorbanceResult) - self.assertEqual(results[0].wavelength, 500) - self.assertEqual(results[0].temperature, 25.0) - self.assertEqual(results[0].timestamp, 12345.6) - - commands = [c.args[0] for c in self.send_command_mock.call_args_list] - self.assertIn("!CLEAR DATA", commands) - self.assertIn("!STRIP 1 12", commands) - self.assertIn("!CSPEED 8", commands) - self.assertIn("!SHAKE OFF", commands) - self.assertIn("!WAVELENGTH 500", commands) - self.assertIn("!CALIBRATE ONCE", commands) - self.assertIn("!MODE ENDPOINT", commands) - self.assertIn("!ORDER COLUMN", commands) - self.assertIn("!SPEED OFF", commands) - - readtype_call = next( - c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE ABSPLA" - ) - self.assertEqual(readtype_call.kwargs, {"num_res_fields": 2}) - - mock_read_now.assert_called_once() - mock_wait_for_idle.assert_called_once() - mock_transfer_data.assert_called_once() + async def test_read_absorbance(self): + with ( + patch.object(self.backend, "_read_now", new_callable=AsyncMock) as mock_read_now, + patch.object(self.driver, "wait_for_idle", new_callable=AsyncMock) as mock_wait, + patch.object( + self.backend, + "_transfer_data", + new_callable=AsyncMock, + return_value=[{"data": [[0.1]], "wavelength": 500, "temperature": 25.0, "time": 12345.6}], + ) as mock_transfer, + ): + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + results = await self.backend.read_absorbance(plate, plate.get_wells(), 500) + + self.assertIsInstance(results, list) + self.assertEqual(len(results), 1) + self.assertIsInstance(results[0], AbsorbanceResult) + self.assertEqual(results[0].wavelength, 500) + self.assertEqual(results[0].temperature, 25.0) + self.assertEqual(results[0].timestamp, 12345.6) + + commands = [c.args[0] for c in self.send_command_mock.call_args_list] + self.assertIn("!CLEAR DATA", commands) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!WAVELENGTH 500", commands) + self.assertIn("!CALIBRATE ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn("!SPEED OFF", commands) + + readtype_call = next( + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE ABSPLA" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 2}) + + mock_read_now.assert_called_once() + mock_wait.assert_called_once() + mock_transfer.assert_called_once() class TestSpectraMaxM5Backend(unittest.IsolatedAsyncioTestCase): - backend: SpectraMaxM5Backend + """Tests for SpectraMaxM5 fluorescence and luminescence backends.""" + + flu_backend: SpectraMaxM5FluorescenceBackend + lum_backend: SpectraMaxM5LuminescenceBackend + driver: MolecularDevicesDriver mock_serial: MagicMock send_command_mock: AsyncMock @@ -515,543 +520,114 @@ def setUp(self): self.mock_serial.readline = AsyncMock(return_value=b"OK>\r\n") with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): - self.backend = SpectraMaxM5Backend(port="COM1") - self.backend.io = self.mock_serial - self.send_command_mock = patch.object( - self.backend, "send_command", new_callable=AsyncMock - ).start() + self.driver = MolecularDevicesDriver( + port="COM1", human_readable_device_name="Molecular Devices SpectraMax M5" + ) + self.driver.io = self.mock_serial + self.flu_backend = SpectraMaxM5FluorescenceBackend(driver=self.driver) + self.lum_backend = SpectraMaxM5LuminescenceBackend(driver=self.driver) + self.send_command_mock = patch.object( + self.driver, "send_command", new_callable=AsyncMock + ).start() self.addCleanup(patch.stopall) - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._wait_for_idle", - new_callable=AsyncMock, - ) - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._transfer_data", - new_callable=AsyncMock, - return_value=[ - { - "data": [[100.0]], - "ex_wavelength": 485, - "em_wavelength": 520, - "temperature": 25.0, - "time": 12345.6, - } - ], - ) - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._read_now", - new_callable=AsyncMock, - ) - async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): - plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - results = await self.backend.read_fluorescence( - plate, plate.get_wells(), excitation_wavelength=485, emission_wavelength=520, focal_height=0 - ) - - # Verify typed results - self.assertIsInstance(results, list) - self.assertEqual(len(results), 1) - self.assertIsInstance(results[0], FluorescenceResult) - self.assertEqual(results[0].excitation_wavelength, 485) - self.assertEqual(results[0].emission_wavelength, 520) - self.assertEqual(results[0].temperature, 25.0) - self.assertEqual(results[0].timestamp, 12345.6) - - commands = [c.args[0] for c in self.send_command_mock.call_args_list] - self.assertIn("!CLEAR DATA", commands) - self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) - self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) - self.assertIn("!STRIP 1 12", commands) - self.assertIn("!CSPEED 8", commands) - self.assertIn("!SHAKE OFF", commands) - self.assertIn("!FPW 10", commands) - self.assertIn("!AUTOPMT ON", commands) - self.assertIn("!EXWAVELENGTH 485", commands) - self.assertIn("!EMWAVELENGTH 520", commands) - self.assertIn("!PMTCAL ONCE", commands) - self.assertIn("!MODE ENDPOINT", commands) - self.assertIn("!ORDER COLUMN", commands) - self.assertIn("!READSTAGE TOP", commands) - - readtype_call = next( - c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE FLU" - ) - self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) - - mock_read_now.assert_called_once() - mock_wait_for_idle.assert_called_once() - mock_transfer_data.assert_called_once() - - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._wait_for_idle", - new_callable=AsyncMock, - ) - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._transfer_data", - new_callable=AsyncMock, - return_value=[{"data": [[1000.0]], "em_wavelength": 590, "temperature": 25.0, "time": 12345.6}], - ) - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._read_now", - new_callable=AsyncMock, - ) - async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): - plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - results = await self.backend.read_luminescence( - plate, - plate.get_wells(), - focal_height=0, - backend_params=SpectraMaxM5Backend.LuminescenceParams(emission_wavelengths=[590]), - ) - - # Verify typed results - self.assertIsInstance(results, list) - self.assertEqual(len(results), 1) - self.assertIsInstance(results[0], LuminescenceResult) - self.assertEqual(results[0].temperature, 25.0) - self.assertEqual(results[0].timestamp, 12345.6) - - commands = [c.args[0] for c in self.send_command_mock.call_args_list] - self.assertIn("!CLEAR DATA", commands) - self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) - self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) - self.assertIn("!STRIP 1 12", commands) - self.assertIn("!CSPEED 8", commands) - self.assertIn("!SHAKE OFF", commands) - self.assertIn("!EMWAVELENGTH 590", commands) - self.assertIn("!PMTCAL ONCE", commands) - self.assertIn("!MODE ENDPOINT", commands) - self.assertIn("!ORDER COLUMN", commands) - self.assertIn("!READSTAGE TOP", commands) - - readtype_call = next( - c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE LUM" - ) - self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) - - mock_read_now.assert_called_once() - mock_wait_for_idle.assert_called_once() - mock_transfer_data.assert_called_once() - - async def test_read_luminescence_requires_emission_wavelengths(self): - plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - with self.assertRaises(ValueError): - await self.backend.read_luminescence(plate, plate.get_wells(), focal_height=0) - - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._wait_for_idle", - new_callable=AsyncMock, - ) - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._transfer_data", - new_callable=AsyncMock, - return_value="", - ) - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._read_now", - new_callable=AsyncMock, - ) - async def test_read_fluorescence_polarization( - self, - mock_read_now, - mock_transfer_data, - mock_wait_for_idle, - ): - plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - await self.backend.read_fluorescence_polarization(plate, [485], [520], [515]) - - commands = [c.args[0] for c in self.send_command_mock.call_args_list] - self.assertIn("!CLEAR DATA", commands) - self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) - self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) - self.assertIn("!STRIP 1 12", commands) - self.assertIn("!CSPEED 8", commands) - self.assertIn("!SHAKE OFF", commands) - self.assertIn("!FPW 10", commands) - self.assertIn("!AUTOPMT ON", commands) - self.assertIn("!EXWAVELENGTH 485", commands) - self.assertIn("!EMWAVELENGTH 520", commands) - self.assertIn("!AUTOFILTER OFF", commands) - self.assertIn("!EMFILTER 515", commands) - self.assertIn("!PMTCAL ONCE", commands) - self.assertIn("!MODE ENDPOINT", commands) - self.assertIn("!ORDER COLUMN", commands) - self.assertIn("!READSTAGE TOP", commands) - - readtype_call = next( - c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE POLAR" - ) - self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) - - mock_read_now.assert_called_once() - mock_wait_for_idle.assert_called_once() - mock_transfer_data.assert_called_once() - - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._wait_for_idle", - new_callable=AsyncMock, - ) - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._transfer_data", - new_callable=AsyncMock, - return_value="", - ) - @patch( - "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesBackend._read_now", - new_callable=AsyncMock, - ) - async def test_read_time_resolved_fluorescence( - self, - mock_read_now, - mock_transfer_data, - mock_wait_for_idle, - ): - plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") - await self.backend.read_time_resolved_fluorescence( - plate, [485], [520], [515], delay_time=10, integration_time=100 - ) - - commands = [c.args[0] for c in self.send_command_mock.call_args_list] - self.assertIn("!CLEAR DATA", commands) - self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) - self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) - self.assertIn("!STRIP 1 12", commands) - self.assertIn("!CSPEED 8", commands) - self.assertIn("!SHAKE OFF", commands) - self.assertIn("!FPW 50", commands) - self.assertIn("!AUTOPMT ON", commands) - self.assertIn("!EXWAVELENGTH 485", commands) - self.assertIn("!EMWAVELENGTH 520", commands) - self.assertIn("!AUTOFILTER OFF", commands) - self.assertIn("!EMFILTER 515", commands) - self.assertIn("!PMTCAL ONCE", commands) - self.assertIn("!MODE ENDPOINT", commands) - self.assertIn("!ORDER COLUMN", commands) - self.assertIn("!COUNTTIMEDELAY 10", commands) - self.assertIn("!COUNTTIME 0.1", commands) - self.assertIn("!READSTAGE TOP", commands) - - readtype_call = next( - c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE TIME 0 250" - ) - self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) - - mock_read_now.assert_called_once() - mock_wait_for_idle.assert_called_once() - mock_transfer_data.assert_called_once() - - -class TestDataParsing(unittest.IsolatedAsyncioTestCase): - send_command_mock: AsyncMock - - def setUp(self): - with patch("pylabrobot.io.serial.Serial", return_value=MagicMock()): - self.backend = MolecularDevicesBackend(port="COM1") - self.send_command_mock = patch.object( - self.backend, "send_command", new_callable=AsyncMock - ).start() - - def test_parse_absorbance_single_wavelength(self): - data_str = """ - 12345.6 25.1 96-well - L: 260 - L: 260 - 1: 0.1 0.2 - 2: 0.3 0.4 - """ - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - - result = self.backend._parse_data(data_str, settings) - self.assertIsInstance(result, list) - self.assertEqual(len(result), 1) - read = result[0] - self.assertEqual(read["wavelength"], 260) - self.assertEqual(read["time"], 12345.6) - self.assertEqual(read["temperature"], 25.1) - self.assertEqual(read["data"], [[0.1, 0.3], [0.2, 0.4]]) - - def test_parse_absorbance_multiple_wavelengths(self): - data_str = """ - 12345.6\t25.1\t96-well - L:\t260\t280 - L:\t260 - 1:\t0.1\t0.2 - 2:\t0.3\t0.4 - L:\t280 - 1:\t0.5\t0.6 - 2:\t0.7\t0.8 - """ - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - result = self.backend._parse_data(data_str, settings) - self.assertIsInstance(result, list) - self.assertEqual(len(result), 2) - self.assertEqual(result[0]["wavelength"], 260) - self.assertEqual(result[0]["data"], [[0.1, 0.3], [0.2, 0.4]]) - self.assertEqual(result[1]["wavelength"], 280) - self.assertEqual(result[1]["data"], [[0.5, 0.7], [0.6, 0.8]]) - - def test_parse_fluorescence(self): - data_str = """ - 12345.6\t25.1\t96-well - exL:\t485 - emL:\t520 - L:\t485\t520 - 1:\t100\t200 - 2:\t300\t400 - """ - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.FLU, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - result = self.backend._parse_data(data_str, settings) - self.assertIsInstance(result, list) - self.assertEqual(len(result), 1) - read = result[0] - self.assertEqual(read["ex_wavelength"], 485) - self.assertEqual(read["em_wavelength"], 520) - self.assertEqual(read["time"], 12345.6) - self.assertEqual(read["temperature"], 25.1) - self.assertEqual(read["data"], [[100.0, 300.0], [200.0, 400.0]]) - - def test_parse_luminescence(self): - data_str = """ - 12345.6\t25.1\t96-well - emL:\t590 - L:\t\t590 - 1:\t1000\t2000 - 2:\t3000\t4000 - """ - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.LUM, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - result = self.backend._parse_data(data_str, settings) - self.assertIsInstance(result, list) - self.assertEqual(len(result), 1) - read = result[0] - self.assertEqual(read["em_wavelength"], 590) - self.assertEqual(read["time"], 12345.6) - self.assertEqual(read["temperature"], 25.1) - self.assertEqual(read["data"], [[1000.0, 3000.0], [2000.0, 4000.0]]) - - def test_parse_data_with_sat_and_nan(self): - data_str = """ - 12345.6\t25.1\t96-well - L:\t260 - L:\t260 - 1:\t0.1\t#SAT - 2:\t0.3\t- - """ - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.ENDPOINT, - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - spectrum_settings=None, - ) - result = self.backend._parse_data(data_str, settings) - self.assertIsInstance(result, list) - self.assertEqual(len(result), 1) - read = result[0] - self.assertEqual(read["data"][1][0], float("inf")) - self.assertTrue(math.isnan(read["data"][1][1])) - - async def test_parse_kinetic_absorbance(self): - def data_generator(): - yield [ - "OK", - """ - 12345.6\t25.1\t96-well - L:\t260 - L:\t260 - 1:\t0.1\t0.2 - 2:\t0.3\t0.4 - """, - ] - yield [ - "OK", - """ - 12355.6\t25.2\t96-well - L:\t260 - L:\t260 - 1:\t0.15\t0.25 - 2:\t0.35\t0.45 - """, - ] - - self.send_command_mock.side_effect = data_generator() - - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.KINETIC, - kinetic_settings=KineticSettings(interval=10, num_readings=2), - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - spectrum_settings=None, - ) - - result = await self.backend._transfer_data(settings) - self.assertEqual(len(result), 2) - self.assertEqual(result[0]["wavelength"], 260) - self.assertEqual(result[0]["data"], [[0.1, 0.3], [0.2, 0.4]]) - self.assertEqual(result[0]["time"], 12345.6) - self.assertEqual(result[1]["wavelength"], 260) - self.assertEqual(result[1]["data"], [[0.15, 0.35], [0.25, 0.45]]) - self.assertEqual(result[1]["time"], 12355.6) - - async def test_parse_spectrum_absorbance(self): - def data_generator(): - yield [ - "OK", - """ - 12345.6\t25.1\t96-well - L:\t260 - L:\t260 - 1:\t0.1\t0.2 - 2:\t0.3\t0.4 - """, - ] - yield [ - "OK", - """ - 12355.6\t25.2\t96-well - L:\t270 - L:\t270 - 1:\t0.15\t0.25 - 2:\t0.35\t0.45 - """, - ] - - self.send_command_mock.side_effect = data_generator() - - settings = MolecularDevicesSettings( - plate=MagicMock(), - read_mode=ReadMode.ABS, - read_type=ReadType.SPECTRUM, - spectrum_settings=SpectrumSettings(start_wavelength=260, step=10, num_steps=2), - read_order=ReadOrder.COLUMN, - calibrate=Calibrate.ON, - shake_settings=None, - carriage_speed=CarriageSpeed.NORMAL, - speed_read=False, - kinetic_settings=None, - ) - - result = await self.backend._transfer_data(settings) - self.assertEqual(len(result), 2) - - self.assertEqual(result[0]["wavelength"], 260) - self.assertEqual(result[0]["data"], [[0.1, 0.3], [0.2, 0.4]]) - self.assertEqual(result[0]["time"], 12345.6) - - self.assertEqual(result[1]["wavelength"], 270) - self.assertEqual(result[1]["data"], [[0.15, 0.35], [0.25, 0.45]]) - self.assertEqual(result[1]["time"], 12355.6) - - -class TestErrorHandling(unittest.IsolatedAsyncioTestCase): - def setUp(self): - self.mock_serial = MagicMock() - self.mock_serial.setup = AsyncMock() - self.mock_serial.stop = AsyncMock() - self.mock_serial.write = AsyncMock() - self.mock_serial.readline = AsyncMock() - - with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): - self.backend = MolecularDevicesBackend(port="/dev/tty01") - self.backend.io = self.mock_serial - - async def _mock_send_command_response(self, response_str: str): - self.mock_serial.readline.side_effect = [response_str.encode() + b">\r\n"] - return await self.backend.send_command("!TEST") - - async def test_parse_basic_errors_fail_known_error_code(self): - with self.assertRaisesRegex( - MolecularDevicesUnrecognizedCommandError, - "Command '!TEST' failed with error 107: no data to transfer", + async def test_read_fluorescence(self): + with ( + patch.object(self.flu_backend, "_read_now", new_callable=AsyncMock) as mock_read_now, + patch.object(self.driver, "wait_for_idle", new_callable=AsyncMock) as mock_wait, + patch.object( + self.flu_backend, + "_transfer_data", + new_callable=AsyncMock, + return_value=[ + { + "data": [[100.0]], + "ex_wavelength": 485, + "em_wavelength": 520, + "temperature": 25.0, + "time": 12345.6, + } + ], + ) as mock_transfer, ): - await self._mock_send_command_response("OK\t\r\n>FAIL\t 107") - - async def test_parse_basic_errors_fail_unknown_error_code(self): - with self.assertRaisesRegex( - MolecularDevicesError, "Command '!TEST' failed with unknown error code: 999" - ): - await self._mock_send_command_response("FAIL\t 999") - - async def test_parse_basic_errors_fail_unparsable_error(self): - with self.assertRaisesRegex( - MolecularDevicesError, "Command '!TEST' failed with unparsable error: FAIL\t ABC" + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + results = await self.flu_backend.read_fluorescence( + plate, plate.get_wells(), excitation_wavelength=485, emission_wavelength=520, focal_height=0 + ) + + self.assertIsInstance(results, list) + self.assertEqual(len(results), 1) + self.assertIsInstance(results[0], FluorescenceResult) + self.assertEqual(results[0].excitation_wavelength, 485) + self.assertEqual(results[0].emission_wavelength, 520) + self.assertEqual(results[0].temperature, 25.0) + self.assertEqual(results[0].timestamp, 12345.6) + + commands = [c.args[0] for c in self.send_command_mock.call_args_list] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!FPW 10", commands) + self.assertIn("!AUTOPMT ON", commands) + self.assertIn("!EXWAVELENGTH 485", commands) + self.assertIn("!EMWAVELENGTH 520", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + self.assertIn("!ORDER COLUMN", commands) + self.assertIn("!READSTAGE TOP", commands) + + readtype_call = next( + c for c in self.send_command_mock.call_args_list if c.args[0] == "!READTYPE FLU" + ) + self.assertEqual(readtype_call.kwargs, {"num_res_fields": 1}) + + mock_read_now.assert_called_once() + mock_wait.assert_called_once() + mock_transfer.assert_called_once() + + async def test_read_luminescence(self): + with ( + patch.object(self.lum_backend, "_read_now", new_callable=AsyncMock) as mock_read_now, + patch.object(self.driver, "wait_for_idle", new_callable=AsyncMock) as mock_wait, + patch.object( + self.lum_backend, + "_transfer_data", + new_callable=AsyncMock, + return_value=[ + {"data": [[1000.0]], "em_wavelength": 590, "temperature": 25.0, "time": 12345.6} + ], + ) as mock_transfer, ): - await self._mock_send_command_response("FAIL\t ABC") - - async def test_parse_basic_errors_empty_response(self): - self.mock_serial.readline.return_value = b"" - with self.assertRaisesRegex(TimeoutError, "Timeout waiting for response to command: !TEST"): - await self.backend.send_command("!TEST", timeout=1) - - async def test_parse_basic_errors_warning_response(self): - self.mock_serial.readline.side_effect = [b"OK\tWarning: Something happened>\r\n"] - try: - await self.backend.send_command("!TEST") - except MolecularDevicesError: - self.fail("MolecularDevicesError raised for a warning response") - - async def test_parse_basic_errors_ok_response(self): - self.mock_serial.readline.side_effect = [b"OK>\r\n"] - try: - response = await self.backend.send_command("!TEST") - self.assertEqual(response, ["OK"]) - except MolecularDevicesError: - self.fail("MolecularDevicesError raised for a valid OK response") - - -if __name__ == "__main__": - unittest.main() + plate = AGenBio_96_wellplate_Ub_2200ul("test_plate") + results = await self.lum_backend.read_luminescence( + plate, + plate.get_wells(), + focal_height=0, + backend_params=SpectraMaxM5LuminescenceBackend.LuminescenceParams( + emission_wavelengths=[590] + ), + ) + + self.assertIsInstance(results, list) + self.assertEqual(len(results), 1) + self.assertIsInstance(results[0], LuminescenceResult) + self.assertEqual(results[0].temperature, 25.0) + self.assertEqual(results[0].timestamp, 12345.6) + + commands = [c.args[0] for c in self.send_command_mock.call_args_list] + self.assertIn("!CLEAR DATA", commands) + self.assertTrue(any(cmd.startswith("!XPOS") for cmd in commands)) + self.assertTrue(any(cmd.startswith("!YPOS") for cmd in commands)) + self.assertIn("!STRIP 1 12", commands) + self.assertIn("!CSPEED 8", commands) + self.assertIn("!SHAKE OFF", commands) + self.assertIn("!EMWAVELENGTH 590", commands) + self.assertIn("!PMTCAL ONCE", commands) + self.assertIn("!MODE ENDPOINT", commands) + + mock_read_now.assert_called_once() + mock_wait.assert_called_once() + mock_transfer.assert_called_once() diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py b/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py index 8c3b9bd1b2f..cdaec275861 100644 --- a/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py +++ b/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py @@ -3,22 +3,24 @@ from pylabrobot.device import Device from pylabrobot.resources import Coordinate, PlateHolder, Resource -from .backend import MolecularDevicesBackend, MolecularDevicesSettings +from .backend import ( + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, + MolecularDevicesSettings, + MolecularDevicesTemperatureBackend, +) -class SpectraMax384PlusBackend(MolecularDevicesBackend): - """Backend for Molecular Devices SpectraMax 384 Plus plate readers. +class SpectraMax384PlusAbsorbanceBackend(MolecularDevicesAbsorbanceBackend): + """Absorbance backend for Molecular Devices SpectraMax 384 Plus plate readers. - Absorbance only. Overrides ``_set_readtype`` (simpler CUV/PLA), and no-ops + Overrides ``_set_readtype`` (simpler CUV/PLA), and no-ops ``_set_nvram`` / ``_set_tag``. """ - def __init__(self, port: str) -> None: - super().__init__(port, human_readable_device_name="Molecular Devices SpectraMax 384 Plus") - async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: cmd = f"!READTYPE {'CUV' if settings.cuvette else 'PLA'}" - await self.send_command(cmd, num_res_fields=1) + await self._driver.send_command(cmd, num_res_fields=1) async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: pass @@ -43,7 +45,9 @@ def __init__( size_y: float = 0.0, # TODO: measure size_z: float = 0.0, # TODO: measure ): - backend = SpectraMax384PlusBackend(port=port) + driver = MolecularDevicesDriver( + port=port, human_readable_device_name="Molecular Devices SpectraMax 384 Plus" + ) Resource.__init__( self, name=name, @@ -52,10 +56,10 @@ def __init__( size_z=size_z, model="Molecular Devices SpectraMax 384 Plus", ) - Device.__init__(self, driver=backend) - self._driver: SpectraMax384PlusBackend = backend - self.absorbance = AbsorbanceCapability(backend=backend) - self.tc = TemperatureControlCapability(backend=backend) + Device.__init__(self, driver=driver) + self._driver: MolecularDevicesDriver = driver + self.absorbance = AbsorbanceCapability(backend=SpectraMax384PlusAbsorbanceBackend(driver)) + self.tc = TemperatureControlCapability(backend=MolecularDevicesTemperatureBackend(driver)) self._capabilities = [self.absorbance, self.tc] self.plate_holder = PlateHolder( @@ -70,9 +74,3 @@ def __init__( def serialize(self) -> dict: return {**Resource.serialize(self), **Device.serialize(self)} - - async def open(self) -> None: - await self._driver.open() - - async def close(self) -> None: - await self._driver.close() diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py index e9dbb2f0ea8..bf591c0f996 100644 --- a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py +++ b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import Dict, List, Optional, Union +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability from pylabrobot.capabilities.plate_reading.fluorescence import FluorescenceCapability from pylabrobot.capabilities.plate_reading.fluorescence.backend import FluorescenceBackend @@ -13,33 +14,31 @@ from pylabrobot.resources import Coordinate, PlateHolder, Resource from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well -from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin from .backend import ( Calibrate, CarriageSpeed, KineticSettings, - MolecularDevicesBackend, + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, MolecularDevicesSettings, + MolecularDevicesTemperatureBackend, PmtGain, ReadMode, ReadOrder, ReadType, ShakeSettings, SpectrumSettings, + _MolecularDevicesProtocol, ) -class SpectraMaxM5Backend(MolecularDevicesBackend, FluorescenceBackend, LuminescenceBackend): - """Backend for Molecular Devices SpectraMax M5 plate readers. - - Supports absorbance (inherited), fluorescence, luminescence, fluorescence polarization, - and time-resolved fluorescence. - """ +class SpectraMaxM5FluorescenceBackend(_MolecularDevicesProtocol, FluorescenceBackend): + """Translates FluorescenceBackend interface into SpectraMax M5 commands.""" - def __init__(self, port: str) -> None: - super().__init__(port, human_readable_device_name="Molecular Devices SpectraMax M5") + def __init__(self, driver: MolecularDevicesDriver) -> None: + self._driver = driver @dataclass class FluorescenceParams(BackendParams): @@ -70,7 +69,7 @@ async def read_fluorescence( backend_params: Optional[SerializableMixin] = None, ) -> List[FluorescenceResult]: if not isinstance(backend_params, self.FluorescenceParams): - backend_params = SpectraMaxM5Backend.FluorescenceParams() + backend_params = SpectraMaxM5FluorescenceBackend.FluorescenceParams() excitation_wavelengths = backend_params.excitation_wavelengths or [excitation_wavelength] emission_wavelengths = backend_params.emission_wavelengths or [emission_wavelength] @@ -118,7 +117,7 @@ async def read_fluorescence( await self._set_readtype(settings) await self._read_now() - await self._wait_for_idle(timeout=backend_params.timeout) + await self._driver.wait_for_idle(timeout=backend_params.timeout) dicts = await self._transfer_data(settings) return [ FluorescenceResult( @@ -131,85 +130,6 @@ async def read_fluorescence( for d in dicts ] - @dataclass - class LuminescenceParams(BackendParams): - emission_wavelengths: Optional[List[int]] = None - read_type: ReadType = ReadType.ENDPOINT - read_order: ReadOrder = ReadOrder.COLUMN - calibrate: Calibrate = Calibrate.ONCE - shake_settings: Optional[ShakeSettings] = None - carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL - read_from_bottom: bool = False - pmt_gain: Union[PmtGain, int] = PmtGain.AUTO - flashes_per_well: int = 0 - kinetic_settings: Optional[KineticSettings] = None - spectrum_settings: Optional[SpectrumSettings] = None - cuvette: bool = False - settling_time: int = 0 - timeout: int = 600 - - async def read_luminescence( - self, - plate: Plate, - wells: List[Well], - focal_height: float, - backend_params: Optional[SerializableMixin] = None, - ) -> List[LuminescenceResult]: - if not isinstance(backend_params, self.LuminescenceParams): - backend_params = SpectraMaxM5Backend.LuminescenceParams() - - if backend_params.emission_wavelengths is None: - raise ValueError("emission_wavelengths is required for SpectraMax M5 luminescence reads") - - settings = MolecularDevicesSettings( - plate=plate, - read_mode=ReadMode.LUM, - read_type=backend_params.read_type, - read_order=backend_params.read_order, - calibrate=backend_params.calibrate, - shake_settings=backend_params.shake_settings, - carriage_speed=backend_params.carriage_speed, - read_from_bottom=backend_params.read_from_bottom, - pmt_gain=backend_params.pmt_gain, - flashes_per_well=backend_params.flashes_per_well, - kinetic_settings=backend_params.kinetic_settings, - spectrum_settings=backend_params.spectrum_settings, - emission_wavelengths=backend_params.emission_wavelengths, - cuvette=backend_params.cuvette, - speed_read=False, - settling_time=backend_params.settling_time, - ) - await self._set_clear() - await self._set_read_stage(settings) - - if not backend_params.cuvette: - await self._set_plate_position(settings) - await self._set_strip(settings) - await self._set_carriage_speed(settings) - - await self._set_shake(settings) - await self._set_pmt(settings) - await self._set_wavelengths(settings) - await self._set_read_stage(settings) - await self._set_calibrate(settings) - await self._set_mode(settings) - await self._set_order(settings) - await self._set_tag(settings) - await self._set_nvram(settings) - await self._set_readtype(settings) - - await self._read_now() - await self._wait_for_idle(timeout=backend_params.timeout) - dicts = await self._transfer_data(settings) - return [ - LuminescenceResult( - data=d["data"], - temperature=d["temperature"], - timestamp=d["time"], - ) - for d in dicts - ] - async def read_fluorescence_polarization( self, plate: Plate, @@ -230,6 +150,7 @@ async def read_fluorescence_polarization( settling_time: int = 0, timeout: int = 600, ) -> List[Dict]: + """Read fluorescence polarization.""" settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.POLAR, @@ -270,7 +191,7 @@ async def read_fluorescence_polarization( await self._set_readtype(settings) await self._read_now() - await self._wait_for_idle(timeout=timeout) + await self._driver.wait_for_idle(timeout=timeout) return await self._transfer_data(settings) async def read_time_resolved_fluorescence( @@ -295,6 +216,7 @@ async def read_time_resolved_fluorescence( settling_time: int = 0, timeout: int = 600, ) -> List[Dict]: + """Read time-resolved fluorescence.""" settings = MolecularDevicesSettings( plate=plate, read_mode=ReadMode.TIME, @@ -337,10 +259,96 @@ async def read_time_resolved_fluorescence( await self._set_nvram(settings) await self._read_now() - await self._wait_for_idle(timeout=timeout) + await self._driver.wait_for_idle(timeout=timeout) return await self._transfer_data(settings) +class SpectraMaxM5LuminescenceBackend(_MolecularDevicesProtocol, LuminescenceBackend): + """Translates LuminescenceBackend interface into SpectraMax M5 commands.""" + + def __init__(self, driver: MolecularDevicesDriver) -> None: + self._driver = driver + + @dataclass + class LuminescenceParams(BackendParams): + emission_wavelengths: Optional[List[int]] = None + read_type: ReadType = ReadType.ENDPOINT + read_order: ReadOrder = ReadOrder.COLUMN + calibrate: Calibrate = Calibrate.ONCE + shake_settings: Optional[ShakeSettings] = None + carriage_speed: CarriageSpeed = CarriageSpeed.NORMAL + read_from_bottom: bool = False + pmt_gain: Union[PmtGain, int] = PmtGain.AUTO + flashes_per_well: int = 0 + kinetic_settings: Optional[KineticSettings] = None + spectrum_settings: Optional[SpectrumSettings] = None + cuvette: bool = False + settling_time: int = 0 + timeout: int = 600 + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + if not isinstance(backend_params, self.LuminescenceParams): + backend_params = SpectraMaxM5LuminescenceBackend.LuminescenceParams() + + if backend_params.emission_wavelengths is None: + raise ValueError("emission_wavelengths is required for SpectraMax M5 luminescence reads") + + settings = MolecularDevicesSettings( + plate=plate, + read_mode=ReadMode.LUM, + read_type=backend_params.read_type, + read_order=backend_params.read_order, + calibrate=backend_params.calibrate, + shake_settings=backend_params.shake_settings, + carriage_speed=backend_params.carriage_speed, + read_from_bottom=backend_params.read_from_bottom, + pmt_gain=backend_params.pmt_gain, + flashes_per_well=backend_params.flashes_per_well, + kinetic_settings=backend_params.kinetic_settings, + spectrum_settings=backend_params.spectrum_settings, + emission_wavelengths=backend_params.emission_wavelengths, + cuvette=backend_params.cuvette, + speed_read=False, + settling_time=backend_params.settling_time, + ) + await self._set_clear() + await self._set_read_stage(settings) + + if not backend_params.cuvette: + await self._set_plate_position(settings) + await self._set_strip(settings) + await self._set_carriage_speed(settings) + + await self._set_shake(settings) + await self._set_pmt(settings) + await self._set_wavelengths(settings) + await self._set_read_stage(settings) + await self._set_calibrate(settings) + await self._set_mode(settings) + await self._set_order(settings) + await self._set_tag(settings) + await self._set_nvram(settings) + await self._set_readtype(settings) + + await self._read_now() + await self._driver.wait_for_idle(timeout=backend_params.timeout) + dicts = await self._transfer_data(settings) + return [ + LuminescenceResult( + data=d["data"], + temperature=d["temperature"], + timestamp=d["time"], + ) + for d in dicts + ] + + # --------------------------------------------------------------------------- # Device # --------------------------------------------------------------------------- @@ -350,8 +358,6 @@ class SpectraMaxM5(Resource, Device): """Molecular Devices SpectraMax M5 plate reader. Supports absorbance, fluorescence, and luminescence capabilities. - Also supports fluorescence polarization and time-resolved fluorescence - via direct backend access. """ def __init__( @@ -362,7 +368,9 @@ def __init__( size_y: float = 0.0, # TODO: measure size_z: float = 0.0, # TODO: measure ): - backend = SpectraMaxM5Backend(port=port) + driver = MolecularDevicesDriver( + port=port, human_readable_device_name="Molecular Devices SpectraMax M5" + ) Resource.__init__( self, name=name, @@ -371,12 +379,12 @@ def __init__( size_z=size_z, model="Molecular Devices SpectraMax M5", ) - Device.__init__(self, driver=backend) - self._driver: SpectraMaxM5Backend = backend - self.absorbance = AbsorbanceCapability(backend=backend) - self.luminescence = LuminescenceCapability(backend=backend) - self.fluorescence = FluorescenceCapability(backend=backend) - self.tc = TemperatureControlCapability(backend=backend) + Device.__init__(self, driver=driver) + self._driver: MolecularDevicesDriver = driver + self.absorbance = AbsorbanceCapability(backend=MolecularDevicesAbsorbanceBackend(driver)) + self.luminescence = LuminescenceCapability(backend=SpectraMaxM5LuminescenceBackend(driver)) + self.fluorescence = FluorescenceCapability(backend=SpectraMaxM5FluorescenceBackend(driver)) + self.tc = TemperatureControlCapability(backend=MolecularDevicesTemperatureBackend(driver)) self._capabilities = [self.absorbance, self.luminescence, self.fluorescence, self.tc] self.plate_holder = PlateHolder( @@ -391,9 +399,3 @@ def __init__( def serialize(self) -> dict: return {**Resource.serialize(self), **Device.serialize(self)} - - async def open(self) -> None: - await self._driver.open() - - async def close(self) -> None: - await self._driver.close() diff --git a/pylabrobot/opentrons/__init__.py b/pylabrobot/opentrons/__init__.py index cb725b8a7b3..45ef90311f4 100644 --- a/pylabrobot/opentrons/__init__.py +++ b/pylabrobot/opentrons/__init__.py @@ -1,5 +1,7 @@ from .temperature_module import ( - OpentronsTemperatureModuleBackend, - OpentronsTemperatureModuleUSBBackend, + OpentronsTemperatureModuleDriver, + OpentronsTemperatureModuleTemperatureBackend, + OpentronsTemperatureModuleUSBDriver, + OpentronsTemperatureModuleUSBTemperatureBackend, OpentronsTemperatureModuleV2, ) diff --git a/pylabrobot/opentrons/temperature_module/__init__.py b/pylabrobot/opentrons/temperature_module/__init__.py index 964582e9e3e..6204da3fc55 100644 --- a/pylabrobot/opentrons/temperature_module/__init__.py +++ b/pylabrobot/opentrons/temperature_module/__init__.py @@ -1,2 +1,9 @@ -from .backend import OpentronsTemperatureModuleBackend, OpentronsTemperatureModuleUSBBackend +from .http_driver import ( + OpentronsTemperatureModuleDriver, + OpentronsTemperatureModuleTemperatureBackend, +) from .temperature_module import OpentronsTemperatureModuleV2 +from .usb_driver import ( + OpentronsTemperatureModuleUSBDriver, + OpentronsTemperatureModuleUSBTemperatureBackend, +) diff --git a/pylabrobot/opentrons/temperature_module/backend.py b/pylabrobot/opentrons/temperature_module/backend.py deleted file mode 100644 index 01633a605e4..00000000000 --- a/pylabrobot/opentrons/temperature_module/backend.py +++ /dev/null @@ -1,137 +0,0 @@ -from typing import Optional, cast - -from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend -from pylabrobot.device import Driver -from pylabrobot.io.serial import Serial - -try: - import ot_api - - USE_OT = True -except ImportError as e: - USE_OT = False - _OT_IMPORT_ERROR = e - - -class OpentronsTemperatureModuleBackend(TemperatureControllerBackend, Driver): - """Backend for the Opentrons Temperature Module v2 via the Opentrons HTTP API.""" - - @property - def supports_active_cooling(self) -> bool: - return False - - def __init__(self, opentrons_id: str): - """Create a new Opentrons temperature module backend. - - Args: - opentrons_id: Opentrons ID of the temperature module. Get it from - ``OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()``. - """ - self.opentrons_id = opentrons_id - - if not USE_OT: - raise RuntimeError( - "Opentrons is not installed. Please run pip install pylabrobot[opentrons]." - f" Import error: {_OT_IMPORT_ERROR}." - ) - - async def setup(self): - pass - - async def stop(self): - await self.deactivate() - - def serialize(self) -> dict: - return {**super().serialize(), "opentrons_id": self.opentrons_id} - - async def set_temperature(self, temperature: float): - ot_api.modules.temperature_module_set_temperature( - celsius=temperature, module_id=self.opentrons_id - ) - - async def deactivate(self): - ot_api.modules.temperature_module_deactivate(module_id=self.opentrons_id) - - async def get_current_temperature(self) -> float: - modules = ot_api.modules.list_connected_modules() - for module in modules: - if module["id"] == self.opentrons_id: - return cast(float, module["data"]["currentTemperature"]) - raise RuntimeError(f"Module with id '{self.opentrons_id}' not found") - - -class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend, Driver): - """Backend for the Opentrons Temperature Module v2 via direct USB serial.""" - - @property - def supports_active_cooling(self) -> bool: - return True - - def __init__(self, port: str): - """Create a new Opentrons temperature module USB backend. - - Args: - port: Serial port for USB communication. - """ - self.port = port - self._serial: Optional[Serial] = None - - @property - def serial(self) -> Serial: - if self._serial is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - return self._serial - - async def setup(self): - self._serial = Serial( - human_readable_device_name="Opentrons Temperature Module", - port=self.port, - baudrate=115200, - timeout=3, - ) - await self._serial.setup() - - async def stop(self): - await self.deactivate() - if self._serial is not None: - await self._serial.stop() - self._serial = None - - def serialize(self) -> dict: - return {**super().serialize(), "port": self.port} - - async def set_temperature(self, temperature: float): - tmp_message = f"M104 S{temperature}\r\n" - await self.serial.write(tmp_message.encode("utf-8")) - response1 = await self.serial.readline() - response2 = await self.serial.readline() - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding='utf-8')} " - f"{response2.decode(encoding='utf-8')}" - ) - - async def deactivate(self): - await self.serial.write(b"M18\r\n") - response1 = await self.serial.readline() - response2 = await self.serial.readline() - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding='utf-8')} " - f"{response2.decode(encoding='utf-8')}" - ) - - async def get_current_temperature(self) -> float: - await self.serial.write(b"M105\r\n") - response = await self.serial.readline() - if b"C" not in response: - raise ValueError(f"Unexpected response from device: {response.decode(encoding='utf-8')}") - - response1 = await self.serial.readline() - response2 = await self.serial.readline() - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding='utf-8')} " - f"{response2.decode(encoding='utf-8')}" - ) - return float(response.strip().split(b"C:")[-1]) diff --git a/pylabrobot/opentrons/temperature_module/http_driver.py b/pylabrobot/opentrons/temperature_module/http_driver.py new file mode 100644 index 00000000000..3bdcf0921c0 --- /dev/null +++ b/pylabrobot/opentrons/temperature_module/http_driver.py @@ -0,0 +1,65 @@ +from typing import cast + +from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend +from pylabrobot.device import Driver + +try: + import ot_api + + USE_OT = True +except ImportError as e: + USE_OT = False + _OT_IMPORT_ERROR = e + + +class OpentronsTemperatureModuleDriver(Driver): + """Driver for the Opentrons Temperature Module v2 via the Opentrons HTTP API. + + Owns the ot_api dependency check. There is no persistent connection to manage, + so ``setup``/``stop`` are lightweight. + """ + + def __init__(self, opentrons_id: str): + super().__init__() + self.opentrons_id = opentrons_id + + if not USE_OT: + raise RuntimeError( + "Opentrons is not installed. Please run pip install pylabrobot[opentrons]." + f" Import error: {_OT_IMPORT_ERROR}." + ) + + async def setup(self): + pass + + async def stop(self): + pass + + def serialize(self) -> dict: + return {**super().serialize(), "opentrons_id": self.opentrons_id} + + +class OpentronsTemperatureModuleTemperatureBackend(TemperatureControllerBackend): + """Translates ``TemperatureControllerBackend`` into Opentrons HTTP-API calls.""" + + def __init__(self, driver: OpentronsTemperatureModuleDriver): + self._driver = driver + + @property + def supports_active_cooling(self) -> bool: + return False + + async def set_temperature(self, temperature: float): + ot_api.modules.temperature_module_set_temperature( + celsius=temperature, module_id=self._driver.opentrons_id + ) + + async def deactivate(self): + ot_api.modules.temperature_module_deactivate(module_id=self._driver.opentrons_id) + + async def get_current_temperature(self) -> float: + modules = ot_api.modules.list_connected_modules() + for module in modules: + if module["id"] == self._driver.opentrons_id: + return cast(float, module["data"]["currentTemperature"]) + raise RuntimeError(f"Module with id '{self._driver.opentrons_id}' not found") diff --git a/pylabrobot/opentrons/temperature_module/temperature_module.py b/pylabrobot/opentrons/temperature_module/temperature_module.py index 3ec4e7283e3..d969e92bd14 100644 --- a/pylabrobot/opentrons/temperature_module/temperature_module.py +++ b/pylabrobot/opentrons/temperature_module/temperature_module.py @@ -2,13 +2,19 @@ from pylabrobot.capabilities.temperature_controlling import ( TemperatureControlCapability, - TemperatureControllerBackend, ) from pylabrobot.device import Device from pylabrobot.resources import Coordinate, ItemizedResource, ResourceHolder from pylabrobot.resources.opentrons.module import OTModule -from .backend import OpentronsTemperatureModuleBackend, OpentronsTemperatureModuleUSBBackend +from .http_driver import ( + OpentronsTemperatureModuleDriver, + OpentronsTemperatureModuleTemperatureBackend, +) +from .usb_driver import ( + OpentronsTemperatureModuleUSBDriver, + OpentronsTemperatureModuleUSBTemperatureBackend, +) class OpentronsTemperatureModuleV2(ResourceHolder, Device, OTModule): @@ -50,12 +56,13 @@ def __init__( if opentrons_id is not None and serial_port is not None: raise ValueError("Exactly one of `opentrons_id` or `serial_port` must be provided.") - backend: TemperatureControllerBackend if serial_port is not None: - backend = OpentronsTemperatureModuleUSBBackend(port=serial_port) + driver = OpentronsTemperatureModuleUSBDriver(port=serial_port) + tc_backend = OpentronsTemperatureModuleUSBTemperatureBackend(driver=driver) else: assert opentrons_id is not None - backend = OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id) + driver = OpentronsTemperatureModuleDriver(opentrons_id=opentrons_id) + tc_backend = OpentronsTemperatureModuleTemperatureBackend(driver=driver) ResourceHolder.__init__( self, @@ -67,9 +74,9 @@ def __init__( category="temperature_controller", model="temperatureModuleV2", ) - Device.__init__(self, driver=backend) - self._driver = backend - self.tc = TemperatureControlCapability(backend=backend) + Device.__init__(self, driver=driver) + self._driver = driver + self.tc = TemperatureControlCapability(backend=tc_backend) self._capabilities = [self.tc] if child is not None: diff --git a/pylabrobot/opentrons/temperature_module/usb_driver.py b/pylabrobot/opentrons/temperature_module/usb_driver.py new file mode 100644 index 00000000000..93729470098 --- /dev/null +++ b/pylabrobot/opentrons/temperature_module/usb_driver.py @@ -0,0 +1,88 @@ +from typing import Optional + +from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend +from pylabrobot.device import Driver +from pylabrobot.io.serial import Serial + + +class OpentronsTemperatureModuleUSBDriver(Driver): + """Driver for the Opentrons Temperature Module v2 via direct USB serial. + + Owns the ``Serial`` connection and its lifecycle. + """ + + def __init__(self, port: str): + super().__init__() + self.port = port + self._serial: Optional[Serial] = None + + @property + def serial(self) -> Serial: + if self._serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + return self._serial + + async def setup(self): + self._serial = Serial( + human_readable_device_name="Opentrons Temperature Module", + port=self.port, + baudrate=115200, + timeout=3, + ) + await self._serial.setup() + + async def stop(self): + if self._serial is not None: + await self._serial.stop() + self._serial = None + + def serialize(self) -> dict: + return {**super().serialize(), "port": self.port} + + async def send_and_check(self, command: bytes): + """Send a command and verify the two-line 'ok' acknowledgement.""" + await self.serial.write(command) + response1 = await self.serial.readline() + response2 = await self.serial.readline() + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError( + f"Unexpected response from device: {response1.decode(encoding='utf-8')} " + f"{response2.decode(encoding='utf-8')}" + ) + + async def query_temperature(self) -> float: + """Send M105 and parse the temperature from the response.""" + await self.serial.write(b"M105\r\n") + response = await self.serial.readline() + if b"C" not in response: + raise ValueError(f"Unexpected response from device: {response.decode(encoding='utf-8')}") + + response1 = await self.serial.readline() + response2 = await self.serial.readline() + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError( + f"Unexpected response from device: {response1.decode(encoding='utf-8')} " + f"{response2.decode(encoding='utf-8')}" + ) + return float(response.strip().split(b"C:")[-1]) + + +class OpentronsTemperatureModuleUSBTemperatureBackend(TemperatureControllerBackend): + """Translates ``TemperatureControllerBackend`` into USB serial driver commands.""" + + def __init__(self, driver: OpentronsTemperatureModuleUSBDriver): + self._driver = driver + + @property + def supports_active_cooling(self) -> bool: + return True + + async def set_temperature(self, temperature: float): + tmp_message = f"M104 S{temperature}\r\n" + await self._driver.send_and_check(tmp_message.encode("utf-8")) + + async def deactivate(self): + await self._driver.send_and_check(b"M18\r\n") + + async def get_current_temperature(self) -> float: + return await self._driver.query_temperature() diff --git a/pylabrobot/qinstruments/__init__.py b/pylabrobot/qinstruments/__init__.py index bc0362513f8..8108891b766 100644 --- a/pylabrobot/qinstruments/__init__.py +++ b/pylabrobot/qinstruments/__init__.py @@ -6,11 +6,13 @@ BioShake3000T, BioShake3000TElm, BioShake5000Elm, - BioShakeBackend, BioShakeD30Elm, BioShakeD30TElm, + BioShakeDriver, BioShakeQ1, BioShakeQ2, + BioShakeShakerBackend, + BioShakeTemperatureBackend, ColdPlate, Heatplate, ) diff --git a/pylabrobot/qinstruments/bioshake.py b/pylabrobot/qinstruments/bioshake.py index e0412591508..2d4bb933410 100644 --- a/pylabrobot/qinstruments/bioshake.py +++ b/pylabrobot/qinstruments/bioshake.py @@ -20,20 +20,19 @@ _SERIAL_IMPORT_ERROR = e -class BioShakeBackend(TemperatureControllerBackend, ShakerBackend, Driver): - """Backend for all QInstruments BioShake models. +class BioShakeDriver(Driver): + """Serial driver for QInstruments BioShake devices. - All models share the same firmware. Commands that are not supported by a given - model will return an error from the device. + Owns the serial connection, command protocol, and device-level operations + (reset, home) that don't belong to any capability. """ - def __init__(self, port: str, timeout: int = 60, supports_active_cooling: bool = False): + def __init__(self, port: str, timeout: int = 60): if not HAS_SERIAL: raise RuntimeError(f"pyserial is required for BioShake. Import error: {_SERIAL_IMPORT_ERROR}") self.port = port self.timeout = timeout - self._supports_active_cooling = supports_active_cooling self.io = Serial( human_readable_device_name="QInstruments BioShake", port=self.port, @@ -45,7 +44,7 @@ def __init__(self, port: str, timeout: int = 60, supports_active_cooling: bool = timeout=self.timeout, ) - async def _send_command(self, cmd: str, delay: float = 0.5, timeout: float = 2): + async def send_command(self, cmd: str, delay: float = 0.5, timeout: float = 2): try: await self.io.reset_input_buffer() await self.io.reset_output_buffer() @@ -78,7 +77,6 @@ async def _send_command(self, cmd: str, delay: float = 0.5, timeout: float = 2): raise RuntimeError(f"Unexpected error while sending '{cmd}': {type(e).__name__}: {e}") from e async def setup(self, skip_home: bool = False): - await Driver.setup(self) await self.io.setup() if not skip_home: await self.reset() @@ -86,7 +84,6 @@ async def setup(self, skip_home: bool = False): await self.home() async def stop(self): - await Driver.stop(self) await self.io.stop() async def reset(self): @@ -115,9 +112,14 @@ async def reset(self): continue async def home(self): - await self._send_command(cmd="shakeGoHome", delay=5) + await self.send_command(cmd="shakeGoHome", delay=5) + - # -- shaking +class BioShakeShakerBackend(ShakerBackend): + """Translates ShakerBackend calls into BioShake serial commands.""" + + def __init__(self, driver: BioShakeDriver): + self._driver = driver async def start_shaking(self, speed: float, acceleration: Union[int, float] = 0): if isinstance(speed, float): @@ -129,14 +131,14 @@ async def start_shaking(self, speed: float, acceleration: Union[int, float] = 0) f"Speed must be an integer or a whole number float, not {type(speed).__name__}" ) - min_speed = int(float(await self._send_command(cmd="getShakeMinRpm", delay=0.2))) - max_speed = int(float(await self._send_command(cmd="getShakeMaxRpm", delay=0.2))) + min_speed = int(float(await self._driver.send_command(cmd="getShakeMinRpm", delay=0.2))) + max_speed = int(float(await self._driver.send_command(cmd="getShakeMaxRpm", delay=0.2))) assert min_speed <= speed <= max_speed, ( f"Speed {speed} RPM is out of range. Allowed range is {min_speed}-{max_speed} RPM" ) - await self._send_command(cmd=f"setShakeTargetSpeed{speed}") + await self._driver.send_command(cmd=f"setShakeTargetSpeed{speed}") if isinstance(acceleration, float): if not acceleration.is_integer(): @@ -147,16 +149,20 @@ async def start_shaking(self, speed: float, acceleration: Union[int, float] = 0) f"Acceleration must be an integer or a whole number float, not {type(acceleration).__name__}" ) - min_accel = int(float(await self._send_command(cmd="getShakeAccelerationMin", delay=0.2))) - max_accel = int(float(await self._send_command(cmd="getShakeAccelerationMax", delay=0.2))) + min_accel = int( + float(await self._driver.send_command(cmd="getShakeAccelerationMin", delay=0.2)) + ) + max_accel = int( + float(await self._driver.send_command(cmd="getShakeAccelerationMax", delay=0.2)) + ) assert min_accel <= acceleration <= max_accel, ( f"Acceleration {acceleration} seconds is out of range. " f"Allowed range is {min_accel}-{max_accel} seconds" ) - await self._send_command(cmd=f"setShakeAcceleration{acceleration}", delay=0.2) - await self._send_command(cmd="shakeOn", delay=0.2) + await self._driver.send_command(cmd=f"setShakeAcceleration{acceleration}", delay=0.2) + await self._driver.send_command(cmd="shakeOn", delay=0.2) async def stop_shaking(self, deceleration: Union[int, float] = 0): if isinstance(deceleration, float): @@ -169,16 +175,20 @@ async def stop_shaking(self, deceleration: Union[int, float] = 0): f"not {type(deceleration).__name__}" ) - min_decel = int(float(await self._send_command(cmd="getShakeAccelerationMin", delay=0.2))) - max_decel = int(float(await self._send_command(cmd="getShakeAccelerationMax", delay=0.2))) + min_decel = int( + float(await self._driver.send_command(cmd="getShakeAccelerationMin", delay=0.2)) + ) + max_decel = int( + float(await self._driver.send_command(cmd="getShakeAccelerationMax", delay=0.2)) + ) assert min_decel <= deceleration <= max_decel, ( f"Deceleration {deceleration} seconds is out of range. " f"Allowed range is {min_decel}-{max_decel} seconds" ) - await self._send_command(cmd=f"setShakeAcceleration{deceleration}", delay=0.2) - await self._send_command(cmd="shakeOff", delay=0.2) + await self._driver.send_command(cmd=f"setShakeAcceleration{deceleration}", delay=0.2) + await self._driver.send_command(cmd="shakeOff", delay=0.2) # The firmware needs the motor to fully decelerate before ELM can operate. await asyncio.sleep(3) @@ -188,20 +198,26 @@ def supports_locking(self) -> bool: return True async def lock_plate(self): - await self._send_command(cmd="setElmLockPos", delay=0.3) + await self._driver.send_command(cmd="setElmLockPos", delay=0.3) async def unlock_plate(self): - await self._send_command(cmd="setElmUnlockPos", delay=0.3) + await self._driver.send_command(cmd="setElmUnlockPos", delay=0.3) - # -- temperature + +class BioShakeTemperatureBackend(TemperatureControllerBackend): + """Translates TemperatureControllerBackend calls into BioShake serial commands.""" + + def __init__(self, driver: BioShakeDriver, supports_active_cooling: bool = False): + self._driver = driver + self._supports_active_cooling = supports_active_cooling @property def supports_active_cooling(self) -> bool: return self._supports_active_cooling async def set_temperature(self, temperature: float): - min_temp = int(float(await self._send_command(cmd="getTempMin", delay=0.2))) - max_temp = int(float(await self._send_command(cmd="getTempMax", delay=0.2))) + min_temp = int(float(await self._driver.send_command(cmd="getTempMin", delay=0.2))) + max_temp = int(float(await self._driver.send_command(cmd="getTempMax", delay=0.2))) assert min_temp <= temperature <= max_temp, ( f"Temperature {temperature} C is out of range. Allowed range is {min_temp}-{max_temp} C." @@ -214,15 +230,15 @@ async def set_temperature(self, temperature: float): raise ValueError(f"Temperature must be a whole number in 1/10 C, not {temperature_tenths}") temperature_tenths = int(temperature_tenths) - await self._send_command(cmd=f"setTempTarget{temperature_tenths}", delay=0.2) - await self._send_command(cmd="tempOn", delay=0.2) + await self._driver.send_command(cmd=f"setTempTarget{temperature_tenths}", delay=0.2) + await self._driver.send_command(cmd="tempOn", delay=0.2) async def get_current_temperature(self) -> float: - response = await self._send_command(cmd="getTempActual", delay=0.2) + response = await self._driver.send_command(cmd="getTempActual", delay=0.2) return float(response) async def deactivate(self): - await self._send_command(cmd="tempOff", delay=0.2) + await self._driver.send_command(cmd="tempOff", delay=0.2) class BioShake(PlateHolder, Device): @@ -238,7 +254,7 @@ def __init__( size_x: float, size_y: float, size_z: float, - driver: BioShakeBackend, + driver: BioShakeDriver, child_location: Coordinate, pedestal_size_z: float, category: str = "bioshake", @@ -256,7 +272,7 @@ def __init__( model=model, ) Device.__init__(self, driver=driver) - self._driver: BioShakeBackend = driver + self._driver: BioShakeDriver = driver def serialize(self) -> dict: return { @@ -274,16 +290,17 @@ class BioShake3000(BioShake): def __init__(self, name: str, port: str): raise NotImplementedError("BioShake3000 is missing resource definition.") + driver = BioShakeDriver(port=port) super().__init__( name=name, - driver=BioShakeBackend(port=port), + driver=driver, size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.shaker = ShakingCapability(backend=self._driver) + self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) self._capabilities = [self.shaker] @@ -292,16 +309,17 @@ class BioShake3000Elm(BioShake): def __init__(self, name: str, port: str): raise NotImplementedError("BioShake3000Elm is missing resource definition.") + driver = BioShakeDriver(port=port) super().__init__( name=name, - driver=BioShakeBackend(port=port), + driver=driver, size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.shaker = ShakingCapability(backend=self._driver) + self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) self._capabilities = [self.shaker] @@ -310,16 +328,17 @@ class BioShake3000ElmDWP(BioShake): def __init__(self, name: str, port: str): raise NotImplementedError("BioShake3000ElmDWP is missing resource definition.") + driver = BioShakeDriver(port=port) super().__init__( name=name, - driver=BioShakeBackend(port=port), + driver=driver, size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.shaker = ShakingCapability(backend=self._driver) + self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) self._capabilities = [self.shaker] @@ -328,16 +347,17 @@ class BioShakeD30Elm(BioShake): def __init__(self, name: str, port: str): raise NotImplementedError("BioShakeD30Elm is missing resource definition.") + driver = BioShakeDriver(port=port) super().__init__( name=name, - driver=BioShakeBackend(port=port), + driver=driver, size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.shaker = ShakingCapability(backend=self._driver) + self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) self._capabilities = [self.shaker] @@ -346,16 +366,17 @@ class BioShake5000Elm(BioShake): def __init__(self, name: str, port: str): raise NotImplementedError("BioShake5000Elm is missing resource definition.") + driver = BioShakeDriver(port=port) super().__init__( name=name, - driver=BioShakeBackend(port=port), + driver=driver, size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.shaker = ShakingCapability(backend=self._driver) + self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) self._capabilities = [self.shaker] @@ -367,17 +388,18 @@ class BioShake3000T(BioShake): def __init__(self, name: str, port: str): raise NotImplementedError("BioShake3000T is missing resource definition.") + driver = BioShakeDriver(port=port) super().__init__( name=name, - driver=BioShakeBackend(port=port), + driver=driver, size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._driver) - self.shaker = ShakingCapability(backend=self._driver) + self.tc = TemperatureControlCapability(backend=BioShakeTemperatureBackend(driver)) + self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) self._capabilities = [self.tc, self.shaker] @@ -386,17 +408,18 @@ class BioShake3000TElm(BioShake): def __init__(self, name: str, port: str): raise NotImplementedError("BioShake3000TElm is missing resource definition.") + driver = BioShakeDriver(port=port) super().__init__( name=name, - driver=BioShakeBackend(port=port), + driver=driver, size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._driver) - self.shaker = ShakingCapability(backend=self._driver) + self.tc = TemperatureControlCapability(backend=BioShakeTemperatureBackend(driver)) + self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) self._capabilities = [self.tc, self.shaker] @@ -405,17 +428,18 @@ class BioShakeD30TElm(BioShake): def __init__(self, name: str, port: str): raise NotImplementedError("BioShakeD30TElm is missing resource definition.") + driver = BioShakeDriver(port=port) super().__init__( name=name, - driver=BioShakeBackend(port=port), + driver=driver, size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._driver) - self.shaker = ShakingCapability(backend=self._driver) + self.tc = TemperatureControlCapability(backend=BioShakeTemperatureBackend(driver)) + self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) self._capabilities = [self.tc, self.shaker] @@ -427,17 +451,20 @@ class BioShakeQ1(BioShake): def __init__(self, name: str, port: str): raise NotImplementedError("BioShakeQ1 is missing resource definition.") + driver = BioShakeDriver(port=port) super().__init__( name=name, - driver=BioShakeBackend(port=port, supports_active_cooling=True), + driver=driver, size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._driver) - self.shaker = ShakingCapability(backend=self._driver) + self.tc = TemperatureControlCapability( + backend=BioShakeTemperatureBackend(driver, supports_active_cooling=True) + ) + self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) self._capabilities = [self.tc, self.shaker] @@ -446,17 +473,20 @@ class BioShakeQ2(BioShake): def __init__(self, name: str, port: str): raise NotImplementedError("BioShakeQ2 is missing resource definition.") + driver = BioShakeDriver(port=port) super().__init__( name=name, - driver=BioShakeBackend(port=port, supports_active_cooling=True), + driver=driver, size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._driver) - self.shaker = ShakingCapability(backend=self._driver) + self.tc = TemperatureControlCapability( + backend=BioShakeTemperatureBackend(driver, supports_active_cooling=True) + ) + self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) self._capabilities = [self.tc, self.shaker] @@ -468,16 +498,17 @@ class Heatplate(BioShake): def __init__(self, name: str, port: str): raise NotImplementedError("Heatplate is missing resource definition.") + driver = BioShakeDriver(port=port) super().__init__( name=name, - driver=BioShakeBackend(port=port), + driver=driver, size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._driver) + self.tc = TemperatureControlCapability(backend=BioShakeTemperatureBackend(driver)) self._capabilities = [self.tc] @@ -486,14 +517,17 @@ class ColdPlate(BioShake): def __init__(self, name: str, port: str): raise NotImplementedError("ColdPlate is missing resource definition.") + driver = BioShakeDriver(port=port) super().__init__( name=name, - driver=BioShakeBackend(port=port, supports_active_cooling=True), + driver=driver, size_x=0, size_y=0, size_z=0, # TODO child_location=Coordinate(0, 0, 0), # TODO pedestal_size_z=0, # TODO ) - self.tc = TemperatureControlCapability(backend=self._driver) + self.tc = TemperatureControlCapability( + backend=BioShakeTemperatureBackend(driver, supports_active_cooling=True) + ) self._capabilities = [self.tc] From eb80265114f3eefed235fa8dbd651ef2ec595fb5 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 26 Mar 2026 00:40:18 -0700 Subject: [PATCH 09/69] Remove Driver from Recording and Chatterbox test backends Recording backends and chatterbox backends are now pure CapabilityBackends. Test devices use a _NullDriver for the Device lifecycle. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../capabilities/microscopy/chatterbox.py | 9 +------ .../microscopy/microscopy_tests.py | 25 ++++++++++--------- .../absorbance/absorbance_tests.py | 25 ++++++++++--------- .../plate_reading/absorbance/chatterbox.py | 9 +------ .../plate_reading/fluorescence/chatterbox.py | 9 +------ .../fluorescence/fluorescence_tests.py | 24 ++++++++++-------- .../plate_reading/luminescence/chatterbox.py | 9 +------ .../luminescence/luminescence_tests.py | 24 ++++++++++-------- 8 files changed, 56 insertions(+), 78 deletions(-) diff --git a/pylabrobot/capabilities/microscopy/chatterbox.py b/pylabrobot/capabilities/microscopy/chatterbox.py index 640f263bb33..7043d671229 100644 --- a/pylabrobot/capabilities/microscopy/chatterbox.py +++ b/pylabrobot/capabilities/microscopy/chatterbox.py @@ -1,7 +1,6 @@ from typing import Optional from pylabrobot.capabilities.microscopy.backend import MicroscopyBackend -from pylabrobot.device import Driver from pylabrobot.capabilities.microscopy.standard import ( Exposure, FocalPosition, @@ -22,15 +21,9 @@ HAS_NUMPY = False -class MicroscopyChatterboxBackend(MicroscopyBackend, Driver): +class MicroscopyChatterboxBackend(MicroscopyBackend): """Mock microscopy backend for testing.""" - async def setup(self) -> None: - pass - - async def stop(self) -> None: - pass - async def capture( self, row: int, diff --git a/pylabrobot/capabilities/microscopy/microscopy_tests.py b/pylabrobot/capabilities/microscopy/microscopy_tests.py index d45c6b6e2f5..151f8fac9ec 100644 --- a/pylabrobot/capabilities/microscopy/microscopy_tests.py +++ b/pylabrobot/capabilities/microscopy/microscopy_tests.py @@ -52,18 +52,20 @@ def _test_plate() -> Plate: ) -class RecordingMicroscopyBackend(MicroscopyBackend, Driver): - """Backend that records all capture calls for assertion.""" - - def __init__(self): - self.calls: List[Tuple] = [] - +class _NullDriver(Driver): async def setup(self) -> None: pass async def stop(self) -> None: pass + +class RecordingMicroscopyBackend(MicroscopyBackend): + """Backend that records all capture calls for assertion.""" + + def __init__(self): + self.calls: List[Tuple] = [] + async def capture( self, row: int, @@ -85,17 +87,16 @@ async def capture( class _TestMicroscope(Device): - def __init__(self, driver): - super().__init__(driver=driver) - self._driver = driver - self.microscopy = MicroscopyCapability(backend=driver) + def __init__(self, backend): + super().__init__(driver=_NullDriver()) + self.microscopy = MicroscopyCapability(backend=backend) self._capabilities = [self.microscopy] class TestMicroscopyCapability(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingMicroscopyBackend() - self.device = _TestMicroscope(driver=self.backend) + self.device = _TestMicroscope(backend=self.backend) await self.device.setup() self.plate = _test_plate() @@ -171,7 +172,7 @@ async def test_capture_requires_setup(self): class TestChatterboxBackend(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_capture(self): backend = MicroscopyChatterboxBackend() - device = _TestMicroscope(driver=backend) + device = _TestMicroscope(backend=backend) await device.setup() plate = _test_plate() diff --git a/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py index 3a5ee920730..d8ee729b8ee 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py @@ -41,18 +41,20 @@ def _test_plate() -> Plate: ) -class RecordingAbsorbanceBackend(AbsorbanceBackend, Driver): - """Backend that records all read_absorbance calls for assertion.""" - - def __init__(self): - self.calls: List[Tuple] = [] - +class _NullDriver(Driver): async def setup(self) -> None: pass async def stop(self) -> None: pass + +class RecordingAbsorbanceBackend(AbsorbanceBackend): + """Backend that records all read_absorbance calls for assertion.""" + + def __init__(self): + self.calls: List[Tuple] = [] + async def read_absorbance( self, plate: Plate, @@ -71,17 +73,16 @@ async def read_absorbance( class _TestDevice(Device): - def __init__(self, driver): - super().__init__(driver=driver) - self._driver = driver - self.absorbance = AbsorbanceCapability(backend=driver) + def __init__(self, backend): + super().__init__(driver=_NullDriver()) + self.absorbance = AbsorbanceCapability(backend=backend) self._capabilities = [self.absorbance] class TestAbsorbanceCapability(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingAbsorbanceBackend() - self.device = _TestDevice(driver=self.backend) + self.device = _TestDevice(backend=self.backend) await self.device.setup() self.plate = _test_plate() @@ -115,7 +116,7 @@ async def test_read_requires_setup(self): class TestAbsorbanceChatterbox(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_read(self): backend = AbsorbanceChatterboxBackend() - device = _TestDevice(driver=backend) + device = _TestDevice(backend=backend) await device.setup() plate = _test_plate() diff --git a/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py b/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py index 89c91f6bbae..b89f88345c7 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/chatterbox.py @@ -2,7 +2,6 @@ from typing import List, Optional from pylabrobot.capabilities.plate_reading.absorbance.backend import AbsorbanceBackend -from pylabrobot.device import Driver from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult from pylabrobot.capabilities.plate_reading.utils import mask_wells from pylabrobot.resources.plate import Plate @@ -10,18 +9,12 @@ from pylabrobot.serializer import SerializableMixin -class AbsorbanceChatterboxBackend(AbsorbanceBackend, Driver): +class AbsorbanceChatterboxBackend(AbsorbanceBackend): """Mock absorbance backend for testing.""" def __init__(self): self.dummy_absorbance: List[List[Optional[float]]] = [[0.0] * 12 for _ in range(8)] - async def setup(self) -> None: - pass - - async def stop(self) -> None: - pass - async def read_absorbance( self, plate: Plate, diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py b/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py index 5fd72962610..28183535b35 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/chatterbox.py @@ -2,7 +2,6 @@ from typing import List, Optional from pylabrobot.capabilities.plate_reading.fluorescence.backend import FluorescenceBackend -from pylabrobot.device import Driver from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult from pylabrobot.capabilities.plate_reading.utils import mask_wells from pylabrobot.resources.plate import Plate @@ -10,18 +9,12 @@ from pylabrobot.serializer import SerializableMixin -class FluorescenceChatterboxBackend(FluorescenceBackend, Driver): +class FluorescenceChatterboxBackend(FluorescenceBackend): """Mock fluorescence backend for testing.""" def __init__(self): self.dummy_fluorescence: List[List[Optional[float]]] = [[0.0] * 12 for _ in range(8)] - async def setup(self) -> None: - pass - - async def stop(self) -> None: - pass - async def read_fluorescence( self, plate: Plate, diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py index df15e35e455..dd2a4065bb0 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py @@ -41,18 +41,20 @@ def _test_plate() -> Plate: ) -class RecordingFluorescenceBackend(FluorescenceBackend, Driver): - """Backend that records all calls for assertion.""" - - def __init__(self): - self.calls: List[tuple] = [] - +class _NullDriver(Driver): async def setup(self) -> None: pass async def stop(self) -> None: pass + +class RecordingFluorescenceBackend(FluorescenceBackend): + """Backend that records all calls for assertion.""" + + def __init__(self): + self.calls: List[tuple] = [] + async def read_fluorescence( self, plate: Plate, @@ -80,16 +82,16 @@ async def read_fluorescence( class _TestDevice(Device): - def __init__(self, driver): - super().__init__(driver=driver) - self.fluorescence = FluorescenceCapability(backend=driver) + def __init__(self, backend): + super().__init__(driver=_NullDriver()) + self.fluorescence = FluorescenceCapability(backend=backend) self._capabilities = [self.fluorescence] class TestFluorescenceCapability(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingFluorescenceBackend() - self.device = _TestDevice(driver=self.backend) + self.device = _TestDevice(backend=self.backend) await self.device.setup() self.plate = _test_plate() @@ -141,7 +143,7 @@ async def test_read_requires_setup(self): class TestFluorescenceChatterbox(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_read(self): backend = FluorescenceChatterboxBackend() - device = _TestDevice(driver=backend) + device = _TestDevice(backend=backend) await device.setup() plate = _test_plate() diff --git a/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py b/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py index f49a066b982..6823337a88d 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/chatterbox.py @@ -2,7 +2,6 @@ from typing import List, Optional from pylabrobot.capabilities.plate_reading.luminescence.backend import LuminescenceBackend -from pylabrobot.device import Driver from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult from pylabrobot.capabilities.plate_reading.utils import mask_wells from pylabrobot.resources.plate import Plate @@ -10,18 +9,12 @@ from pylabrobot.serializer import SerializableMixin -class LuminescenceChatterboxBackend(LuminescenceBackend, Driver): +class LuminescenceChatterboxBackend(LuminescenceBackend): """Mock luminescence backend for testing.""" def __init__(self): self.dummy_luminescence: List[List[Optional[float]]] = [[0.0] * 12 for _ in range(8)] - async def setup(self) -> None: - pass - - async def stop(self) -> None: - pass - async def read_luminescence( self, plate: Plate, diff --git a/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py index b8d98caddab..e7de553c140 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py @@ -41,18 +41,20 @@ def _test_plate() -> Plate: ) -class RecordingLuminescenceBackend(LuminescenceBackend, Driver): - """Backend that records all calls for assertion.""" - - def __init__(self): - self.calls: List[tuple] = [] - +class _NullDriver(Driver): async def setup(self) -> None: pass async def stop(self) -> None: pass + +class RecordingLuminescenceBackend(LuminescenceBackend): + """Backend that records all calls for assertion.""" + + def __init__(self): + self.calls: List[tuple] = [] + async def read_luminescence( self, plate: Plate, @@ -68,16 +70,16 @@ async def read_luminescence( class _TestDevice(Device): - def __init__(self, driver): - super().__init__(driver=driver) - self.luminescence = LuminescenceCapability(backend=driver) + def __init__(self, backend): + super().__init__(driver=_NullDriver()) + self.luminescence = LuminescenceCapability(backend=backend) self._capabilities = [self.luminescence] class TestLuminescenceCapability(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingLuminescenceBackend() - self.device = _TestDevice(driver=self.backend) + self.device = _TestDevice(backend=self.backend) await self.device.setup() self.plate = _test_plate() @@ -109,7 +111,7 @@ async def test_read_requires_setup(self): class TestLuminescenceChatterbox(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_read(self): backend = LuminescenceChatterboxBackend() - device = _TestDevice(driver=backend) + device = _TestDevice(backend=backend) await device.setup() plate = _test_plate() From 1a7b3ec49d6a6e9d5f21aa8e83f2e0bfcd21313f Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 26 Mar 2026 10:01:37 -0700 Subject: [PATCH 10/69] Remove _NullDriver/_TestDevice from capability tests Test capabilities directly via cap._on_setup() instead of wrapping in a fake Device. Co-Authored-By: Claude Opus 4.6 (1M context) --- creating-capabilities.md | 14 ++++++- .../microscopy/microscopy_tests.py | 37 ++++--------------- .../absorbance/absorbance_tests.py | 35 ++++-------------- .../fluorescence/fluorescence_tests.py | 35 ++++-------------- .../luminescence/luminescence_tests.py | 35 ++++-------------- 5 files changed, 41 insertions(+), 115 deletions(-) diff --git a/creating-capabilities.md b/creating-capabilities.md index e01834268a6..a057d4920c1 100644 --- a/creating-capabilities.md +++ b/creating-capabilities.md @@ -322,8 +322,8 @@ await pico._driver.open_door() ### Chatterbox backends (testing) -Chatterbox backends are pure `CapabilityBackend` subclasses with no driver. They return -dummy data for device-free testing: +Chatterbox backends are pure `CapabilityBackend` subclasses — they do **not** extend `Driver`. +They have no I/O and return dummy data for device-free testing: ```python class MyFanChatterboxBackend(FanBackend): @@ -336,6 +336,16 @@ class MyFanChatterboxBackend(FanBackend): pass ``` +To test a capability without a real device, create it directly and call `_on_setup()`: + +```python +async def test_something(self): + backend = MyFanChatterboxBackend() + cap = FanControlCapability(backend=backend) + await cap._on_setup() + await cap.turn_on(intensity=50) +``` + ## Naming conventions | Thing | Pattern | Example | diff --git a/pylabrobot/capabilities/microscopy/microscopy_tests.py b/pylabrobot/capabilities/microscopy/microscopy_tests.py index 151f8fac9ec..fa2a793da0e 100644 --- a/pylabrobot/capabilities/microscopy/microscopy_tests.py +++ b/pylabrobot/capabilities/microscopy/microscopy_tests.py @@ -20,7 +20,6 @@ ImagingResult, Objective, ) -from pylabrobot.device import Device, Driver from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType @@ -52,14 +51,6 @@ def _test_plate() -> Plate: ) -class _NullDriver(Driver): - async def setup(self) -> None: - pass - - async def stop(self) -> None: - pass - - class RecordingMicroscopyBackend(MicroscopyBackend): """Backend that records all capture calls for assertion.""" @@ -86,25 +77,15 @@ async def capture( ) -class _TestMicroscope(Device): - def __init__(self, backend): - super().__init__(driver=_NullDriver()) - self.microscopy = MicroscopyCapability(backend=backend) - self._capabilities = [self.microscopy] - - class TestMicroscopyCapability(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingMicroscopyBackend() - self.device = _TestMicroscope(backend=self.backend) - await self.device.setup() + self.cap = MicroscopyCapability(backend=self.backend) + await self.cap._on_setup() self.plate = _test_plate() - async def asyncTearDown(self): - await self.device.stop() - async def test_capture_with_tuple_well(self): - result = await self.device.microscopy.capture( + result = await self.cap.capture( well=(2, 5), mode=ImagingMode.BRIGHTFIELD, objective=Objective.O_10X_PL_FL, @@ -126,7 +107,7 @@ async def test_capture_with_tuple_well(self): async def test_capture_with_well_object(self): well = self.plate.get_well("C7") - await self.device.microscopy.capture( + await self.cap.capture( well=well, mode=ImagingMode.DAPI, objective=Objective.O_20X_PL_FL, @@ -145,7 +126,7 @@ async def test_capture_with_well_object(self): self.assertEqual(col, expected_col) async def test_capture_machine_auto(self): - await self.device.microscopy.capture( + await self.cap.capture( well=(0, 0), mode=ImagingMode.GFP, objective=Objective.O_4X_PL_FL, @@ -172,11 +153,11 @@ async def test_capture_requires_setup(self): class TestChatterboxBackend(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_capture(self): backend = MicroscopyChatterboxBackend() - device = _TestMicroscope(backend=backend) - await device.setup() + cap = MicroscopyCapability(backend=backend) + await cap._on_setup() plate = _test_plate() - result = await device.microscopy.capture( + result = await cap.capture( well=(0, 0), mode=ImagingMode.BRIGHTFIELD, objective=Objective.O_4X_PL_FL, @@ -189,8 +170,6 @@ async def test_chatterbox_capture(self): self.assertAlmostEqual(result.exposure_time, 10.0) self.assertAlmostEqual(result.focal_height, 1.0) - await device.stop() - if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py index d8ee729b8ee..ee65296aabd 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py @@ -9,7 +9,6 @@ AbsorbanceChatterboxBackend, ) from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult -from pylabrobot.device import Device, Driver from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType @@ -41,14 +40,6 @@ def _test_plate() -> Plate: ) -class _NullDriver(Driver): - async def setup(self) -> None: - pass - - async def stop(self) -> None: - pass - - class RecordingAbsorbanceBackend(AbsorbanceBackend): """Backend that records all read_absorbance calls for assertion.""" @@ -72,26 +63,16 @@ async def read_absorbance( return [AbsorbanceResult(data=data, wavelength=wavelength, temperature=None, timestamp=0.0)] -class _TestDevice(Device): - def __init__(self, backend): - super().__init__(driver=_NullDriver()) - self.absorbance = AbsorbanceCapability(backend=backend) - self._capabilities = [self.absorbance] - - class TestAbsorbanceCapability(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingAbsorbanceBackend() - self.device = _TestDevice(backend=self.backend) - await self.device.setup() + self.cap = AbsorbanceCapability(backend=self.backend) + await self.cap._on_setup() self.plate = _test_plate() - async def asyncTearDown(self): - await self.device.stop() - async def test_read_with_wells(self): wells = [self.plate.get_well("A1"), self.plate.get_well("B2")] - results = await self.device.absorbance.read(plate=self.plate, wavelength=450, wells=wells) + results = await self.cap.read(plate=self.plate, wavelength=450, wells=wells) self.assertEqual(len(self.backend.calls), 1) _, recorded_wells, recorded_wl = self.backend.calls[0] self.assertEqual(recorded_wells, wells) @@ -100,7 +81,7 @@ async def test_read_with_wells(self): self.assertEqual(results[0].wavelength, 450) async def test_read_all_wells(self): - results = await self.device.absorbance.read(plate=self.plate, wavelength=600) + results = await self.cap.read(plate=self.plate, wavelength=600) self.assertEqual(len(self.backend.calls), 1) _, recorded_wells, _ = self.backend.calls[0] self.assertEqual(len(recorded_wells), 96) @@ -116,12 +97,12 @@ async def test_read_requires_setup(self): class TestAbsorbanceChatterbox(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_read(self): backend = AbsorbanceChatterboxBackend() - device = _TestDevice(backend=backend) - await device.setup() + cap = AbsorbanceCapability(backend=backend) + await cap._on_setup() plate = _test_plate() wells = [plate.get_well("A1"), plate.get_well("H12")] - results = await device.absorbance.read(plate=plate, wavelength=450, wells=wells) + results = await cap.read(plate=plate, wavelength=450, wells=wells) self.assertEqual(len(results), 1) self.assertEqual(results[0].wavelength, 450) # Only requested wells should have data @@ -129,8 +110,6 @@ async def test_chatterbox_read(self): self.assertIsNotNone(results[0].data[7][11]) # H12 self.assertIsNone(results[0].data[0][1]) # A2 not requested - await device.stop() - if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py index dd2a4065bb0..0e1d2578701 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py @@ -9,7 +9,6 @@ ) from pylabrobot.capabilities.plate_reading.fluorescence.fluorescence import FluorescenceCapability from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult -from pylabrobot.device import Device, Driver from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType @@ -41,14 +40,6 @@ def _test_plate() -> Plate: ) -class _NullDriver(Driver): - async def setup(self) -> None: - pass - - async def stop(self) -> None: - pass - - class RecordingFluorescenceBackend(FluorescenceBackend): """Backend that records all calls for assertion.""" @@ -81,26 +72,16 @@ async def read_fluorescence( ] -class _TestDevice(Device): - def __init__(self, backend): - super().__init__(driver=_NullDriver()) - self.fluorescence = FluorescenceCapability(backend=backend) - self._capabilities = [self.fluorescence] - - class TestFluorescenceCapability(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingFluorescenceBackend() - self.device = _TestDevice(backend=self.backend) - await self.device.setup() + self.cap = FluorescenceCapability(backend=self.backend) + await self.cap._on_setup() self.plate = _test_plate() - async def asyncTearDown(self): - await self.device.stop() - async def test_read_with_wells(self): wells = [self.plate.get_well("A1"), self.plate.get_well("B2")] - results = await self.device.fluorescence.read( + results = await self.cap.read( plate=self.plate, excitation_wavelength=485, emission_wavelength=528, @@ -118,7 +99,7 @@ async def test_read_with_wells(self): self.assertAlmostEqual(fh, 8.5) async def test_read_all_wells(self): - results = await self.device.fluorescence.read( + results = await self.cap.read( plate=self.plate, excitation_wavelength=485, emission_wavelength=528, @@ -143,12 +124,12 @@ async def test_read_requires_setup(self): class TestFluorescenceChatterbox(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_read(self): backend = FluorescenceChatterboxBackend() - device = _TestDevice(backend=backend) - await device.setup() + cap = FluorescenceCapability(backend=backend) + await cap._on_setup() plate = _test_plate() wells = [plate.get_well("A1"), plate.get_well("C3")] - results = await device.fluorescence.read( + results = await cap.read( plate=plate, excitation_wavelength=485, emission_wavelength=528, @@ -161,8 +142,6 @@ async def test_chatterbox_read(self): self.assertEqual(results[0].data[0][0], 0.0) self.assertIsNone(results[0].data[1][0]) - await device.stop() - if __name__ == "__main__": unittest.main() diff --git a/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py index e7de553c140..91655625afd 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py @@ -9,7 +9,6 @@ ) from pylabrobot.capabilities.plate_reading.luminescence.luminescence import LuminescenceCapability from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult -from pylabrobot.device import Device, Driver from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d from pylabrobot.resources.well import Well, WellBottomType @@ -41,14 +40,6 @@ def _test_plate() -> Plate: ) -class _NullDriver(Driver): - async def setup(self) -> None: - pass - - async def stop(self) -> None: - pass - - class RecordingLuminescenceBackend(LuminescenceBackend): """Backend that records all calls for assertion.""" @@ -69,26 +60,16 @@ async def read_luminescence( return [LuminescenceResult(data=data, temperature=25.0, timestamp=0.0)] -class _TestDevice(Device): - def __init__(self, backend): - super().__init__(driver=_NullDriver()) - self.luminescence = LuminescenceCapability(backend=backend) - self._capabilities = [self.luminescence] - - class TestLuminescenceCapability(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingLuminescenceBackend() - self.device = _TestDevice(backend=self.backend) - await self.device.setup() + self.cap = LuminescenceCapability(backend=self.backend) + await self.cap._on_setup() self.plate = _test_plate() - async def asyncTearDown(self): - await self.device.stop() - async def test_read_with_wells(self): wells = [self.plate.get_well("A1"), self.plate.get_well("B2")] - results = await self.device.luminescence.read(plate=self.plate, focal_height=13.0, wells=wells) + results = await self.cap.read(plate=self.plate, focal_height=13.0, wells=wells) self.assertEqual(len(results), 1) self.assertEqual(len(self.backend.calls), 1) _, n_wells, fh = self.backend.calls[0] @@ -96,7 +77,7 @@ async def test_read_with_wells(self): self.assertAlmostEqual(fh, 13.0) async def test_read_all_wells(self): - results = await self.device.luminescence.read(plate=self.plate, focal_height=13.0) + results = await self.cap.read(plate=self.plate, focal_height=13.0) self.assertEqual(len(results), 1) _, n_wells, _ = self.backend.calls[0] self.assertEqual(n_wells, 96) @@ -111,20 +92,18 @@ async def test_read_requires_setup(self): class TestLuminescenceChatterbox(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_read(self): backend = LuminescenceChatterboxBackend() - device = _TestDevice(backend=backend) - await device.setup() + cap = LuminescenceCapability(backend=backend) + await cap._on_setup() plate = _test_plate() wells = [plate.get_well("A1"), plate.get_well("C3")] - results = await device.luminescence.read(plate=plate, focal_height=13.0, wells=wells) + results = await cap.read(plate=plate, focal_height=13.0, wells=wells) self.assertEqual(len(results), 1) # A1 = row 0, col 0 => measured self.assertEqual(results[0].data[0][0], 0.0) # B1 = row 1, col 0 => not measured self.assertIsNone(results[0].data[1][0]) - await device.stop() - if __name__ == "__main__": unittest.main() From f2a241e76b95cb8b65895aa1cf381ffb8db3c5a8 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 26 Mar 2026 17:40:26 -0700 Subject: [PATCH 11/69] Fix lint, format, and mypy errors for CI compliance - Fix import sorting across 10+ files (ruff format --fix) - Fix MolecularDevices legacy backend: reference renamed class, update test mocks to patch at correct level (Driver/Protocol instead of legacy wrapper) - Fix Pico legacy tests: split Driver/MicroscopyBackend usage to match new architecture - Fix Opentrons temp module: add base-type annotations for if/else branches - Fix Liconic: use _on_setup/_on_stop (CapabilityBackend API) - Fix Azenta A4S: type: ignore[safe-super] for abstract Driver methods - Fix Pico backend: self._snap_images() instead of self._driver._snap_images() Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/agilent/biotek/biotek.py | 2 +- pylabrobot/agilent/biotek/cytation.py | 2 +- pylabrobot/azenta/a4s.py | 4 +- pylabrobot/azenta/xpeel.py | 2 +- pylabrobot/byonoy/luminescence_96.py | 2 +- pylabrobot/capabilities/capability.py | 1 - pylabrobot/capabilities/microscopy/backend.py | 2 +- .../plate_reading/absorbance/backend.py | 2 +- .../plate_reading/fluorescence/backend.py | 2 +- .../plate_reading/luminescence/backend.py | 2 +- .../molecular_devices/pico/backend_tests.py | 80 +++++----- .../molecular_devices/backend.py | 16 +- .../molecular_devices/backend_tests.py | 142 +++++++++--------- .../spectramax_384_plus_backend.py | 2 +- .../opentrons_backend.py | 2 + .../opentrons_backend_usb.py | 2 + pylabrobot/liconic/liconic.py | 4 +- .../imageXpress/pico/backend.py | 2 +- .../temperature_module/temperature_module.py | 5 +- 19 files changed, 142 insertions(+), 134 deletions(-) diff --git a/pylabrobot/agilent/biotek/biotek.py b/pylabrobot/agilent/biotek/biotek.py index c64a371cada..4b727b28de9 100644 --- a/pylabrobot/agilent/biotek/biotek.py +++ b/pylabrobot/agilent/biotek/biotek.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Dict, Iterable, List, Optional, Tuple +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceBackend, AbsorbanceResult from pylabrobot.capabilities.plate_reading.fluorescence import ( FluorescenceBackend, @@ -18,7 +19,6 @@ from pylabrobot.device import Driver from pylabrobot.io.ftdi import FTDI from pylabrobot.resources import Plate, Well -from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin logger = logging.getLogger(__name__) diff --git a/pylabrobot/agilent/biotek/cytation.py b/pylabrobot/agilent/biotek/cytation.py index 8c55b85da5c..0a435dc04ac 100644 --- a/pylabrobot/agilent/biotek/cytation.py +++ b/pylabrobot/agilent/biotek/cytation.py @@ -8,6 +8,7 @@ from typing import List, Literal, Optional, Tuple, Union from pylabrobot.agilent.biotek.biotek import BioTekBackend +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.microscopy import ( MicroscopyBackend, MicroscopyCapability, @@ -26,7 +27,6 @@ from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability from pylabrobot.device import Device from pylabrobot.resources import Coordinate, Plate, PlateHolder, Resource -from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin try: diff --git a/pylabrobot/azenta/a4s.py b/pylabrobot/azenta/a4s.py index 6417a006d65..ac324784bd0 100644 --- a/pylabrobot/azenta/a4s.py +++ b/pylabrobot/azenta/a4s.py @@ -86,13 +86,13 @@ def __init__(self, port: str, timeout: int = 20) -> None: ) async def setup(self): - await super().setup() + await super().setup() # type: ignore[safe-super] await self.io.setup() await self.system_reset() async def stop(self): await self.set_heater(on=False) - await super().stop() + await super().stop() # type: ignore[safe-super] await self.io.stop() # -- serial protocol -- diff --git a/pylabrobot/azenta/xpeel.py b/pylabrobot/azenta/xpeel.py index 79eafeeedcb..e164d6d37bd 100644 --- a/pylabrobot/azenta/xpeel.py +++ b/pylabrobot/azenta/xpeel.py @@ -11,10 +11,10 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.peeling import PeelerBackend, PeelingCapability from pylabrobot.device import Device, Driver from pylabrobot.io.serial import Serial -from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index 5ee139e7a77..e148354bf8d 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -3,6 +3,7 @@ from typing import List, Optional, Tuple from pylabrobot.byonoy.backend import ByonoyBase, ByonoyDevice +from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_reading.luminescence import ( LuminescenceBackend, LuminescenceCapability, @@ -15,7 +16,6 @@ from pylabrobot.resources.plate import Plate from pylabrobot.resources.rotation import Rotation from pylabrobot.resources.well import Well -from pylabrobot.capabilities.capability import BackendParams from pylabrobot.serializer import SerializableMixin from pylabrobot.utils.list import reshape_2d diff --git a/pylabrobot/capabilities/capability.py b/pylabrobot/capabilities/capability.py index 2303bcd0a8b..0ad155267d1 100644 --- a/pylabrobot/capabilities/capability.py +++ b/pylabrobot/capabilities/capability.py @@ -7,7 +7,6 @@ from pylabrobot.serializer import SerializableMixin - if sys.version_info < (3, 10): from typing_extensions import ParamSpec else: diff --git a/pylabrobot/capabilities/microscopy/backend.py b/pylabrobot/capabilities/microscopy/backend.py index 8006a943b67..7c178bde190 100644 --- a/pylabrobot/capabilities/microscopy/backend.py +++ b/pylabrobot/capabilities/microscopy/backend.py @@ -1,6 +1,7 @@ from abc import ABCMeta, abstractmethod from typing import Optional +from pylabrobot.capabilities.capability import CapabilityBackend from pylabrobot.capabilities.microscopy.standard import ( Exposure, FocalPosition, @@ -9,7 +10,6 @@ ImagingResult, Objective, ) -from pylabrobot.capabilities.capability import CapabilityBackend from pylabrobot.resources.plate import Plate from pylabrobot.serializer import SerializableMixin diff --git a/pylabrobot/capabilities/plate_reading/absorbance/backend.py b/pylabrobot/capabilities/plate_reading/absorbance/backend.py index 816c54b2e3e..48c061456ff 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/backend.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/backend.py @@ -3,8 +3,8 @@ from abc import ABCMeta, abstractmethod from typing import List, Optional -from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well from pylabrobot.serializer import SerializableMixin diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/backend.py b/pylabrobot/capabilities/plate_reading/fluorescence/backend.py index 6a0111d7e2b..ae9eb69e126 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/backend.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/backend.py @@ -3,8 +3,8 @@ from abc import ABCMeta, abstractmethod from typing import List, Optional -from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well from pylabrobot.serializer import SerializableMixin diff --git a/pylabrobot/capabilities/plate_reading/luminescence/backend.py b/pylabrobot/capabilities/plate_reading/luminescence/backend.py index b5820b96d97..2090b0fd77d 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/backend.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/backend.py @@ -3,8 +3,8 @@ from abc import ABCMeta, abstractmethod from typing import List, Optional -from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult from pylabrobot.resources.plate import Plate from pylabrobot.resources.well import Well from pylabrobot.serializer import SerializableMixin diff --git a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py index 228c304e202..8bb74fcb40b 100644 --- a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py +++ b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py @@ -38,6 +38,7 @@ _OBJ_SVC, _SNAP_SVC, PicoDriver, + PicoMicroscopyBackend, _decode_intermediate_response, _extract_image_buffer, _get_image_info, @@ -180,20 +181,23 @@ def _make_backend( objectives=None, filter_cubes=None, lock_timeout=3600, -) -> Tuple[PicoDriver, _MockChannel]: - """Create a PicoDriver with a mock channel, bypassing setup().""" - backend = PicoDriver( +) -> Tuple[PicoDriver, PicoMicroscopyBackend, _MockChannel]: + """Create a PicoDriver + PicoMicroscopyBackend with a mock channel, bypassing setup().""" + driver = PicoDriver( host="127.0.0.1", port=8091, lock_timeout=lock_timeout, + ) + microscopy = PicoMicroscopyBackend( + driver=driver, objectives=objectives or {}, filter_cubes=filter_cubes or {}, ) channel = _MockChannel() - backend._channel = channel - backend._lock_id = "pylabrobot" - backend._locked = True - return backend, channel + driver._channel = channel + driver._lock_id = "pylabrobot" + driver._locked = True + return driver, microscopy, channel def _decode_sila_string_from_request(data: bytes) -> str: @@ -223,7 +227,8 @@ def _unwrap_sila_string(data: bytes) -> str: class TestSetup(unittest.IsolatedAsyncioTestCase): async def test_setup_sends_correct_sequence(self): """setup() with no objectives/filter_cubes: unlock stale, lock, query hardware.""" - backend = PicoDriver(host="127.0.0.1", lock_timeout=120) + driver = PicoDriver(host="127.0.0.1", lock_timeout=120) + microscopy = PicoMicroscopyBackend(driver=driver) channel = _MockChannel() channel.set_response(f"/{_LOCK_SVC}/UnlockServer", b"") @@ -238,7 +243,8 @@ async def test_setup_sends_correct_sequence(self): ) with patch("grpc.insecure_channel", return_value=channel): - await backend.setup() + await driver.setup() + await microscopy._on_setup() self.assertEqual(len(channel.calls), 4) self.assertEqual(channel.calls[0].path, f"/{_LOCK_SVC}/UnlockServer") @@ -254,8 +260,9 @@ async def test_setup_sends_correct_sequence(self): async def test_setup_configures_objectives_and_filter_cubes(self): """When objectives/filter_cubes are specified, setup() calls ChangeHardware.""" - backend = PicoDriver( - host="127.0.0.1", + driver = PicoDriver(host="127.0.0.1") + microscopy = PicoMicroscopyBackend( + driver=driver, objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.DAPI}, ) @@ -284,7 +291,8 @@ async def test_setup_configures_objectives_and_filter_cubes(self): channel.set_response(f"/{_FC_SVC}/ChangeHardware", b"") with patch("grpc.insecure_channel", return_value=channel): - await backend.setup() + await driver.setup() + await microscopy._on_setup() # Verify ChangeHardware was called with correct JSON params obj_change_calls = channel.get_calls(f"/{_OBJ_SVC}/ChangeHardware") @@ -307,7 +315,7 @@ async def test_setup_configures_objectives_and_filter_cubes(self): class TestStop(unittest.IsolatedAsyncioTestCase): async def test_stop_sends_unlock(self): - backend, channel = _make_backend() + backend, _, channel = _make_backend() await backend.stop() @@ -324,7 +332,7 @@ async def test_stop_sends_unlock(self): class TestDoorCommands(unittest.IsolatedAsyncioTestCase): async def test_open_door(self): - backend, channel = _make_backend() + backend, _, channel = _make_backend() channel.set_response(f"/{_INST_SVC}/Initialize", b"") channel.set_response(f"/{_HW_SVC}/OpenPlateDrawer", b"") @@ -338,7 +346,7 @@ async def test_open_door(self): self.assertTrue(backend.door_open) async def test_close_door(self): - backend, channel = _make_backend() + backend, _, channel = _make_backend() backend._door_open = True channel.set_response(f"/{_INST_SVC}/Initialize", b"") channel.set_response(f"/{_HW_SVC}/ClosePlateDrawer", b"") @@ -360,11 +368,11 @@ async def test_close_door(self): class TestObjectiveMaintenanceCommands(unittest.IsolatedAsyncioTestCase): async def test_enter_maintenance(self): - backend, channel = _make_backend() + _, microscopy, channel = _make_backend() channel.set_response(f"/{_INST_SVC}/Initialize", b"") channel.set_response(f"/{_OBJ_SVC}/EnterObjectiveMaintenance", b"") - await backend.enter_objective_maintenance(2) + await microscopy.enter_objective_maintenance(2) self.assertEqual(len(channel.calls), 2) self.assertEqual(channel.calls[0].path, f"/{_INST_SVC}/Initialize") @@ -374,10 +382,10 @@ async def test_enter_maintenance(self): self.assertEqual(params, {"Index": 2}) async def test_exit_maintenance(self): - backend, channel = _make_backend() + _, microscopy, channel = _make_backend() channel.set_response(f"/{_OBJ_SVC}/ExitObjectiveMaintenance", b"") - await backend.exit_objective_maintenance() + await microscopy.exit_objective_maintenance() self.assertEqual(len(channel.calls), 1) self.assertEqual(channel.calls[0].path, f"/{_OBJ_SVC}/ExitObjectiveMaintenance") @@ -392,7 +400,7 @@ async def test_exit_maintenance(self): class TestGetConfiguration(unittest.IsolatedAsyncioTestCase): async def test_decodes_instrument_configuration(self): - backend, channel = _make_backend() + backend, _, channel = _make_backend() config = { "InstrumentConfiguration": { "objectivesComponent": {"objectives": [{"Id": "4x", "Magnification": 4}]}, @@ -420,7 +428,7 @@ async def test_decodes_instrument_configuration(self): class TestChangeHardwareCommands(unittest.IsolatedAsyncioTestCase): async def test_change_objective(self): - backend, channel = _make_backend() + _, microscopy, channel = _make_backend() available = [{"Id": "PL FLUOTAR 4x/0.13"}, {"Id": "PL FLUOTAR 10x/0.30"}] channel.set_response( f"/{_OBJ_SVC}/GetAvailableObjectivesForPosition", @@ -428,7 +436,7 @@ async def test_change_objective(self): ) channel.set_response(f"/{_OBJ_SVC}/ChangeHardware", b"") - await backend.change_objective(1, "PL FLUOTAR 10x/0.30") + await microscopy.change_objective(1, "PL FLUOTAR 10x/0.30") # Query available, then change self.assertEqual(len(channel.calls), 2) @@ -443,14 +451,14 @@ async def test_change_objective(self): self.assertTrue(channel.calls[1].has_lock_metadata) async def test_change_objective_rejects_invalid_id(self): - backend, channel = _make_backend() + _, microscopy, channel = _make_backend() channel.set_response( f"/{_OBJ_SVC}/GetAvailableObjectivesForPosition", _sila_string_response(json.dumps({"objectives": [{"Id": "4x"}]})), ) with self.assertRaises(ValueError) as ctx: - await backend.change_objective(0, "INVALID") + await microscopy.change_objective(0, "INVALID") self.assertIn("not compatible", str(ctx.exception)) # Only the query was sent, ChangeHardware was NOT called @@ -458,14 +466,14 @@ async def test_change_objective_rejects_invalid_id(self): self.assertEqual(channel.calls[0].path, f"/{_OBJ_SVC}/GetAvailableObjectivesForPosition") async def test_change_filter_cube(self): - backend, channel = _make_backend() + _, microscopy, channel = _make_backend() channel.set_response( f"/{_FC_SVC}/Get_CompatibleFilterCubes", _sila_string_response(json.dumps({"filterCubes": [{"Id": "DAPI"}, {"Id": "FITC"}]})), ) channel.set_response(f"/{_FC_SVC}/ChangeHardware", b"") - await backend.change_filter_cube(1, "FITC") + await microscopy.change_filter_cube(1, "FITC") self.assertEqual(len(channel.calls), 2) self.assertEqual(channel.calls[0].path, f"/{_FC_SVC}/Get_CompatibleFilterCubes") @@ -504,7 +512,7 @@ def _setup_capture_channel( async def test_capture_sends_correct_snap_params(self): """Verify SnapImages request contains the right labware + snap JSON.""" - backend, channel = _make_backend( + _, microscopy, channel = _make_backend( objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.DAPI}, ) @@ -519,7 +527,7 @@ async def test_capture_sends_correct_snap_params(self): } self._setup_capture_channel(channel, "uuid-1", snap_event) - await backend.capture( + await microscopy.capture( row=3, column=7, mode=ImagingMode.DAPI, @@ -576,7 +584,7 @@ async def test_capture_sends_correct_snap_params(self): async def test_capture_auto_exposure_and_autofocus(self): """When exposure_time='auto' and focal_height='auto', verify params.""" - backend, channel = _make_backend( + _, microscopy, channel = _make_backend( objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.DAPI}, ) @@ -591,7 +599,7 @@ async def test_capture_auto_exposure_and_autofocus(self): } self._setup_capture_channel(channel, "uuid-2", snap_event) - await backend.capture( + await microscopy.capture( row=0, column=0, mode=ImagingMode.DAPI, @@ -615,7 +623,7 @@ async def test_capture_auto_exposure_and_autofocus(self): async def test_capture_observable_command_flow(self): """Verify the 3-step observable command protocol: start, stream, result.""" - backend, channel = _make_backend( + _, microscopy, channel = _make_backend( objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.DAPI}, ) @@ -630,7 +638,7 @@ async def test_capture_observable_command_flow(self): } self._setup_capture_channel(channel, "exec-uuid-abc", snap_event) - result = await backend.capture( + result = await microscopy.capture( row=0, column=0, mode=ImagingMode.DAPI, @@ -666,7 +674,7 @@ async def test_capture_observable_command_flow(self): async def test_capture_multi_chunk_reassembly(self): """Verify image data is correctly reassembled from multiple chunks.""" - backend, channel = _make_backend( + _, microscopy, channel = _make_backend( objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.DAPI}, ) @@ -703,7 +711,7 @@ async def test_capture_multi_chunk_reassembly(self): ], ) - result = await backend.capture( + result = await microscopy.capture( row=0, column=0, mode=ImagingMode.DAPI, @@ -720,7 +728,7 @@ async def test_capture_multi_chunk_reassembly(self): async def test_capture_brightfield_uses_correct_illumination(self): """Brightfield mode uses different light_channel/excitation_source.""" - backend, channel = _make_backend( + _, microscopy, channel = _make_backend( objectives={0: Objective.O_4X_PL_FL}, filter_cubes={0: ImagingMode.BRIGHTFIELD}, ) @@ -735,7 +743,7 @@ async def test_capture_brightfield_uses_correct_illumination(self): } self._setup_capture_channel(channel, "uuid-bf", snap_event) - await backend.capture( + await microscopy.capture( row=0, column=0, mode=ImagingMode.BRIGHTFIELD, diff --git a/pylabrobot/legacy/plate_reading/molecular_devices/backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py index 6b9f6a2c179..c645bec35a6 100644 --- a/pylabrobot/legacy/plate_reading/molecular_devices/backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py @@ -7,12 +7,15 @@ Calibrate, CarriageSpeed, KineticSettings, + MolecularDevicesAbsorbanceBackend, + MolecularDevicesDriver, MolecularDevicesError, MolecularDevicesFirmwareError, MolecularDevicesHardwareError, MolecularDevicesMotionError, MolecularDevicesNVRAMError, MolecularDevicesSettings, + MolecularDevicesTemperatureBackend, MolecularDevicesUnrecognizedCommandError, PmtGain, ReadMode, @@ -21,11 +24,6 @@ ShakeSettings, SpectrumSettings, ) -from pylabrobot.molecular_devices.spectramax.backend import ( - MolecularDevicesAbsorbanceBackend, - MolecularDevicesDriver, - MolecularDevicesTemperatureBackend, -) from pylabrobot.molecular_devices.spectramax.spectramax_m5 import ( SpectraMaxM5FluorescenceBackend, SpectraMaxM5LuminescenceBackend, @@ -34,7 +32,7 @@ class MolecularDevicesBackend(PlateReaderBackend): - """Legacy. Use pylabrobot.molecular_devices.spectramax.MolecularDevicesBackend instead. + """Legacy. Use pylabrobot.molecular_devices.spectramax instead. Delegates to the new capability-based backend, adapting read method signatures and return types (List[Dict]) for backward compatibility. @@ -100,13 +98,9 @@ async def read_absorbance( # type: ignore[override] settling_time: int = 0, timeout: int = 600, ) -> List[Dict]: - from pylabrobot.molecular_devices.spectramax.backend import ( - MolecularDevicesBackend as NewMDBackend, - ) - wl0 = wavelengths[0] wavelength = wl0[0] if isinstance(wl0, tuple) else wl0 - params = NewMDBackend.AbsorbanceParams( + params = MolecularDevicesAbsorbanceBackend.AbsorbanceParams( wavelengths=wavelengths, read_type=read_type, read_order=read_order, diff --git a/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py b/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py index d7879484438..bd28ce7637c 100644 --- a/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py @@ -34,16 +34,16 @@ def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): self.backend = MolecularDevicesBackend(port="COM1") - self.backend._new.io = self.mock_serial + self.backend._driver.io = self.mock_serial self.send_command_mock = patch.object( - self.backend, "send_command", new_callable=AsyncMock + self.backend._driver, "send_command", new_callable=AsyncMock ).start() self.addCleanup(patch.stopall) async def test_setup_stop(self): # un-mock send_command for this test with patch.object( - self.backend, "send_command", wraps=self.backend.send_command + self.backend._driver, "send_command", wraps=self.backend._driver.send_command ) as wrapped_send_command: await self.backend.setup() self.mock_serial.setup.assert_called_once() @@ -52,7 +52,7 @@ async def test_setup_stop(self): self.mock_serial.stop.assert_called_once() async def test_set_clear(self): - await self.backend._new._set_clear() + await self.backend._absorbance._set_clear() self.send_command_mock.assert_called_once_with("!CLEAR DATA") async def test_set_mode(self): @@ -68,24 +68,24 @@ async def test_set_mode(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_mode(settings) + await self.backend._absorbance._set_mode(settings) self.send_command_mock.assert_called_once_with("!MODE ENDPOINT") self.send_command_mock.reset_mock() settings.read_type = ReadType.KINETIC settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) - await self.backend._new._set_mode(settings) + await self.backend._absorbance._set_mode(settings) self.send_command_mock.assert_called_once_with("!MODE KINETIC 10 5") self.send_command_mock.reset_mock() settings.read_type = ReadType.SPECTRUM settings.spectrum_settings = SpectrumSettings(start_wavelength=200, step=10, num_steps=50) - await self.backend._new._set_mode(settings) + await self.backend._absorbance._set_mode(settings) self.send_command_mock.assert_called_once_with("!MODE SPECTRUM 200 10 50") self.send_command_mock.reset_mock() settings.spectrum_settings.excitation_emission_type = "EXSPECTRUM" - await self.backend._new._set_mode(settings) + await self.backend._absorbance._set_mode(settings) self.send_command_mock.assert_called_once_with("!MODE EXSPECTRUM 200 10 50") async def test_set_wavelengths(self): @@ -102,25 +102,25 @@ async def test_set_wavelengths(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_wavelengths(settings) + await self.backend._absorbance._set_wavelengths(settings) self.send_command_mock.assert_called_once_with("!WAVELENGTH 500 F600") self.send_command_mock.reset_mock() settings.path_check = True - await self.backend._new._set_wavelengths(settings) + await self.backend._absorbance._set_wavelengths(settings) self.send_command_mock.assert_called_once_with("!WAVELENGTH 500 F600 900 998") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.FLU settings.excitation_wavelengths = [485] settings.emission_wavelengths = [520] - await self.backend._new._set_wavelengths(settings) + await self.backend._absorbance._set_wavelengths(settings) self.send_command_mock.assert_has_calls([call("!EXWAVELENGTH 485"), call("!EMWAVELENGTH 520")]) self.send_command_mock.reset_mock() settings.read_mode = ReadMode.LUM settings.emission_wavelengths = [590] - await self.backend._new._set_wavelengths(settings) + await self.backend._absorbance._set_wavelengths(settings) self.send_command_mock.assert_called_once_with("!EMWAVELENGTH 590") async def test_set_plate_position(self): @@ -137,7 +137,7 @@ async def test_set_plate_position(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_plate_position(settings) + await self.backend._absorbance._set_plate_position(settings) self.send_command_mock.assert_has_calls( [call("!XPOS 13.380 9.000 12"), call("!YPOS 12.240 9.000 8")] ) @@ -156,7 +156,7 @@ async def test_set_strip(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_strip(settings) + await self.backend._absorbance._set_strip(settings) self.send_command_mock.assert_called_once_with("!STRIP 1 12") async def test_set_shake(self): @@ -172,18 +172,18 @@ async def test_set_shake(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_shake(settings) + await self.backend._absorbance._set_shake(settings) self.send_command_mock.assert_called_once_with("!SHAKE OFF") self.send_command_mock.reset_mock() settings.shake_settings = ShakeSettings(before_read=True, before_read_duration=5) - await self.backend._new._set_shake(settings) + await self.backend._absorbance._set_shake(settings) self.send_command_mock.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 5 0 0 0 0")]) self.send_command_mock.reset_mock() settings.shake_settings = ShakeSettings(between_reads=True, between_reads_duration=3) settings.kinetic_settings = KineticSettings(interval=10, num_readings=5) - await self.backend._new._set_shake(settings) + await self.backend._absorbance._set_shake(settings) self.send_command_mock.assert_has_calls([call("!SHAKE ON"), call("!SHAKE 0 10 7 3 0")]) async def test_set_carriage_speed(self): @@ -199,11 +199,11 @@ async def test_set_carriage_speed(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_carriage_speed(settings) + await self.backend._absorbance._set_carriage_speed(settings) self.send_command_mock.assert_called_once_with("!CSPEED 8") self.send_command_mock.reset_mock() settings.carriage_speed = CarriageSpeed.SLOW - await self.backend._new._set_carriage_speed(settings) + await self.backend._absorbance._set_carriage_speed(settings) self.send_command_mock.assert_called_once_with("!CSPEED 1") async def test_set_read_stage(self): @@ -219,15 +219,15 @@ async def test_set_read_stage(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_read_stage(settings) + await self.backend._absorbance._set_read_stage(settings) self.send_command_mock.assert_called_once_with("!READSTAGE TOP") self.send_command_mock.reset_mock() settings.read_from_bottom = True - await self.backend._new._set_read_stage(settings) + await self.backend._absorbance._set_read_stage(settings) self.send_command_mock.assert_called_once_with("!READSTAGE BOT") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS - await self.backend._new._set_read_stage(settings) + await self.backend._absorbance._set_read_stage(settings) self.send_command_mock.assert_not_called() async def test_set_flashes_per_well(self): @@ -244,11 +244,11 @@ async def test_set_flashes_per_well(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_flashes_per_well(settings) + await self.backend._absorbance._set_flashes_per_well(settings) self.send_command_mock.assert_called_once_with("!FPW 10") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS - await self.backend._new._set_flashes_per_well(settings) + await self.backend._absorbance._set_flashes_per_well(settings) self.send_command_mock.assert_not_called() async def test_set_pmt(self): @@ -265,19 +265,19 @@ async def test_set_pmt(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_pmt(settings) + await self.backend._absorbance._set_pmt(settings) self.send_command_mock.assert_called_once_with("!AUTOPMT ON") self.send_command_mock.reset_mock() settings.pmt_gain = PmtGain.HIGH - await self.backend._new._set_pmt(settings) + await self.backend._absorbance._set_pmt(settings) self.send_command_mock.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT HIGH")]) self.send_command_mock.reset_mock() settings.pmt_gain = 9 - await self.backend._new._set_pmt(settings) + await self.backend._absorbance._set_pmt(settings) self.send_command_mock.assert_has_calls([call("!AUTOPMT OFF"), call("!PMT 9")]) self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS - await self.backend._new._set_pmt(settings) + await self.backend._absorbance._set_pmt(settings) self.send_command_mock.assert_not_called() async def test_set_filter(self): @@ -290,20 +290,20 @@ async def test_set_filter(self): shake_settings=None, carriage_speed=CarriageSpeed.NORMAL, speed_read=False, - cutoff_filters=[self.backend._new._get_cutoff_filter_index_from_wavelength(535), 9], + cutoff_filters=[self.backend._absorbance._get_cutoff_filter_index_from_wavelength(535), 9], kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_filter(settings) + await self.backend._absorbance._set_filter(settings) self.send_command_mock.assert_has_calls([call("!AUTOFILTER OFF"), call("!EMFILTER 8 9")]) self.send_command_mock.reset_mock() settings.cutoff_filters = [] - await self.backend._new._set_filter(settings) + await self.backend._absorbance._set_filter(settings) self.send_command_mock.assert_called_once_with("!AUTOFILTER ON") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS settings.cutoff_filters = [515, 530] - await self.backend._new._set_filter(settings) + await self.backend._absorbance._set_filter(settings) self.send_command_mock.assert_called_once_with("!AUTOFILTER ON") async def test_set_calibrate(self): @@ -319,11 +319,11 @@ async def test_set_calibrate(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_calibrate(settings) + await self.backend._absorbance._set_calibrate(settings) self.send_command_mock.assert_called_once_with("!CALIBRATE ON") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.FLU - await self.backend._new._set_calibrate(settings) + await self.backend._absorbance._set_calibrate(settings) self.send_command_mock.assert_called_once_with("!PMTCAL ON") async def test_set_order(self): @@ -339,11 +339,11 @@ async def test_set_order(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_order(settings) + await self.backend._absorbance._set_order(settings) self.send_command_mock.assert_called_once_with("!ORDER COLUMN") self.send_command_mock.reset_mock() settings.read_order = ReadOrder.WAVELENGTH - await self.backend._new._set_order(settings) + await self.backend._absorbance._set_order(settings) self.send_command_mock.assert_called_once_with("!ORDER WAVELENGTH") async def test_set_speed(self): @@ -359,15 +359,15 @@ async def test_set_speed(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_speed(settings) + await self.backend._absorbance._set_speed(settings) self.send_command_mock.assert_called_once_with("!SPEED ON") self.send_command_mock.reset_mock() settings.speed_read = False - await self.backend._new._set_speed(settings) + await self.backend._absorbance._set_speed(settings) self.send_command_mock.assert_called_once_with("!SPEED OFF") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.FLU - await self.backend._new._set_speed(settings) + await self.backend._absorbance._set_speed(settings) self.send_command_mock.assert_not_called() async def test_set_integration_time(self): @@ -383,11 +383,11 @@ async def test_set_integration_time(self): kinetic_settings=None, spectrum_settings=None, ) - await self.backend._new._set_integration_time(settings, 10, 100) + await self.backend._absorbance._set_integration_time(settings, 10, 100) self.send_command_mock.assert_has_calls([call("!COUNTTIMEDELAY 10"), call("!COUNTTIME 0.1")]) self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS - await self.backend._new._set_integration_time(settings, 10, 100) + await self.backend._absorbance._set_integration_time(settings, 10, 100) self.send_command_mock.assert_not_called() async def test_set_nvram_polar(self): @@ -404,7 +404,7 @@ async def test_set_nvram_polar(self): spectrum_settings=None, settling_time=5, ) - await self.backend._new._set_nvram(settings) + await self.backend._absorbance._set_nvram(settings) self.send_command_mock.assert_called_once_with("!NVRAM FPSETTLETIME 5") async def test_set_nvram_other(self): @@ -421,11 +421,11 @@ async def test_set_nvram_other(self): spectrum_settings=None, settling_time=10, ) - await self.backend._new._set_nvram(settings) + await self.backend._absorbance._set_nvram(settings) self.send_command_mock.assert_called_once_with("!NVRAM CARCOL 100") self.send_command_mock.reset_mock() settings.settling_time = 110 - await self.backend._new._set_nvram(settings) + await self.backend._absorbance._set_nvram(settings) self.send_command_mock.assert_called_once_with("!NVRAM CARCOL 110") async def test_set_tag(self): @@ -441,29 +441,29 @@ async def test_set_tag(self): kinetic_settings=KineticSettings(interval=10, num_readings=5), spectrum_settings=None, ) - await self.backend._new._set_tag(settings) + await self.backend._absorbance._set_tag(settings) self.send_command_mock.assert_called_once_with("!TAG ON") self.send_command_mock.reset_mock() settings.read_type = ReadType.ENDPOINT - await self.backend._new._set_tag(settings) + await self.backend._absorbance._set_tag(settings) self.send_command_mock.assert_called_once_with("!TAG OFF") self.send_command_mock.reset_mock() settings.read_mode = ReadMode.ABS settings.read_type = ReadType.KINETIC - await self.backend._new._set_tag(settings) + await self.backend._absorbance._set_tag(settings) self.send_command_mock.assert_called_once_with("!TAG OFF") @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesDriver.wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._read_now", new_callable=AsyncMock, ) async def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): @@ -491,16 +491,16 @@ async def test_read_absorbance(self, mock_read_now, mock_transfer_data, mock_wai mock_transfer_data.assert_called_once() @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesDriver.wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._read_now", new_callable=AsyncMock, ) async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): @@ -535,16 +535,16 @@ async def test_read_fluorescence(self, mock_read_now, mock_transfer_data, mock_w mock_transfer_data.assert_called_once() @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesDriver.wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._read_now", new_callable=AsyncMock, ) async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_wait_for_idle): @@ -574,16 +574,16 @@ async def test_read_luminescence(self, mock_read_now, mock_transfer_data, mock_w mock_transfer_data.assert_called_once() @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesDriver.wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._read_now", new_callable=AsyncMock, ) async def test_read_fluorescence_polarization( @@ -623,16 +623,16 @@ async def test_read_fluorescence_polarization( mock_transfer_data.assert_called_once() @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._wait_for_idle", + "pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesDriver.wait_for_idle", new_callable=AsyncMock, ) @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._transfer_data", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._transfer_data", new_callable=AsyncMock, return_value="", ) @patch( - "pylabrobot.legacy.plate_reading.molecular_devices.backend.MolecularDevicesBackend._read_now", + "pylabrobot.molecular_devices.spectramax.backend._MolecularDevicesProtocol._read_now", new_callable=AsyncMock, ) async def test_read_time_resolved_fluorescence( @@ -683,7 +683,7 @@ def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=MagicMock()): self.backend = MolecularDevicesBackend(port="COM1") self.send_command_mock = patch.object( - self.backend, "send_command", new_callable=AsyncMock + self.backend._driver, "send_command", new_callable=AsyncMock ).start() def test_parse_absorbance_single_wavelength(self): @@ -707,7 +707,7 @@ def test_parse_absorbance_single_wavelength(self): spectrum_settings=None, ) - result = self.backend._new._parse_data(data_str, settings) + result = self.backend._absorbance._parse_data(data_str, settings) self.assertIsInstance(result, list) self.assertEqual(len(result), 1) read = result[0] @@ -739,7 +739,7 @@ def test_parse_absorbance_multiple_wavelengths(self): kinetic_settings=None, spectrum_settings=None, ) - result = self.backend._new._parse_data(data_str, settings) + result = self.backend._absorbance._parse_data(data_str, settings) self.assertIsInstance(result, list) self.assertEqual(len(result), 2) self.assertEqual(result[0]["wavelength"], 260) @@ -768,7 +768,7 @@ def test_parse_fluorescence(self): kinetic_settings=None, spectrum_settings=None, ) - result = self.backend._new._parse_data(data_str, settings) + result = self.backend._absorbance._parse_data(data_str, settings) self.assertIsInstance(result, list) self.assertEqual(len(result), 1) read = result[0] @@ -798,7 +798,7 @@ def test_parse_luminescence(self): kinetic_settings=None, spectrum_settings=None, ) - result = self.backend._new._parse_data(data_str, settings) + result = self.backend._absorbance._parse_data(data_str, settings) self.assertIsInstance(result, list) self.assertEqual(len(result), 1) read = result[0] @@ -827,7 +827,7 @@ def test_parse_data_with_sat_and_nan(self): kinetic_settings=None, spectrum_settings=None, ) - result = self.backend._new._parse_data(data_str, settings) + result = self.backend._absorbance._parse_data(data_str, settings) self.assertIsInstance(result, list) self.assertEqual(len(result), 1) read = result[0] @@ -873,7 +873,7 @@ def data_generator(): spectrum_settings=None, ) - result = await self.backend._new._transfer_data(settings) + result = await self.backend._absorbance._transfer_data(settings) self.assertEqual(len(result), 2) self.assertEqual(result[0]["wavelength"], 260) self.assertEqual(result[0]["data"], [[0.1, 0.3], [0.2, 0.4]]) @@ -921,7 +921,7 @@ def data_generator(): kinetic_settings=None, ) - result = await self.backend._new._transfer_data(settings) + result = await self.backend._absorbance._transfer_data(settings) self.assertEqual(len(result), 2) self.assertEqual(result[0]["wavelength"], 260) @@ -943,7 +943,7 @@ def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): self.backend = MolecularDevicesBackend(port="/dev/tty01") - self.backend._new.io = self.mock_serial + self.backend._driver.io = self.mock_serial async def _mock_send_command_response(self, response_str: str): self.mock_serial.readline.side_effect = [response_str.encode() + b">\r\n"] diff --git a/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py index c7ca9068cb8..d3e7fc9187d 100644 --- a/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py @@ -6,13 +6,13 @@ Calibrate, CarriageSpeed, KineticSettings, + MolecularDevicesDriver, PmtGain, ReadOrder, ReadType, ShakeSettings, SpectrumSettings, ) -from pylabrobot.molecular_devices.spectramax.backend import MolecularDevicesDriver from pylabrobot.molecular_devices.spectramax.spectramax_384_plus import ( SpectraMax384PlusAbsorbanceBackend, ) diff --git a/pylabrobot/legacy/temperature_controlling/opentrons_backend.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py index 2f88e706769..5a39c09bcd9 100644 --- a/pylabrobot/legacy/temperature_controlling/opentrons_backend.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py @@ -5,6 +5,8 @@ ) from pylabrobot.opentrons.temperature_module import ( OpentronsTemperatureModuleDriver, +) +from pylabrobot.opentrons.temperature_module import ( OpentronsTemperatureModuleTemperatureBackend as _NewBackend, ) diff --git a/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py index 0bb2043fe4f..6cd0ae1e64a 100644 --- a/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py @@ -5,6 +5,8 @@ ) from pylabrobot.opentrons.temperature_module import ( OpentronsTemperatureModuleUSBDriver, +) +from pylabrobot.opentrons.temperature_module import ( OpentronsTemperatureModuleUSBTemperatureBackend as _NewBackend, ) diff --git a/pylabrobot/liconic/liconic.py b/pylabrobot/liconic/liconic.py index 7ebdd0b4bae..6409e9c51ae 100644 --- a/pylabrobot/liconic/liconic.py +++ b/pylabrobot/liconic/liconic.py @@ -97,14 +97,14 @@ def racks(self) -> List[PlateCarrier]: async def setup(self, **backend_kwargs): if self.barcode_scanner is not None: - await self.barcode_scanner.backend.setup() + await self.barcode_scanner.backend._on_setup() await super().setup() await self._driver.set_racks(self._racks) async def stop(self): await super().stop() if self.barcode_scanner is not None: - await self.barcode_scanner.backend.stop() + await self.barcode_scanner.backend._on_stop() def get_num_free_sites(self) -> int: return sum(len(rack.get_free_sites()) for rack in self._racks) diff --git a/pylabrobot/molecular_devices/imageXpress/pico/backend.py b/pylabrobot/molecular_devices/imageXpress/pico/backend.py index d0ab686052f..9079f0353f3 100644 --- a/pylabrobot/molecular_devices/imageXpress/pico/backend.py +++ b/pylabrobot/molecular_devices/imageXpress/pico/backend.py @@ -724,7 +724,7 @@ async def capture( snap_params["focusSettings"]["baseZPositionUm"] = base_z_um labware_params = _labware_params_from_plate(plate) - images = await self._driver._snap_images(labware_params, snap_params) + images = await self._snap_images(labware_params, snap_params) result_images: List = [] actual_exposure_us = exposure_us diff --git a/pylabrobot/opentrons/temperature_module/temperature_module.py b/pylabrobot/opentrons/temperature_module/temperature_module.py index d969e92bd14..cfeefefd7e9 100644 --- a/pylabrobot/opentrons/temperature_module/temperature_module.py +++ b/pylabrobot/opentrons/temperature_module/temperature_module.py @@ -2,8 +2,9 @@ from pylabrobot.capabilities.temperature_controlling import ( TemperatureControlCapability, + TemperatureControllerBackend, ) -from pylabrobot.device import Device +from pylabrobot.device import Device, Driver from pylabrobot.resources import Coordinate, ItemizedResource, ResourceHolder from pylabrobot.resources.opentrons.module import OTModule @@ -56,6 +57,8 @@ def __init__( if opentrons_id is not None and serial_port is not None: raise ValueError("Exactly one of `opentrons_id` or `serial_port` must be provided.") + driver: Driver + tc_backend: TemperatureControllerBackend if serial_port is not None: driver = OpentronsTemperatureModuleUSBDriver(port=serial_port) tc_backend = OpentronsTemperatureModuleUSBTemperatureBackend(driver=driver) From 0c91c9d6f62e46d3ed2866160c0a019cdab12e77 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 26 Mar 2026 17:41:41 -0700 Subject: [PATCH 12/69] Add v1b1 branch to CI workflow triggers Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/lint.yml | 1 + .github/workflows/test.yml | 1 + .github/workflows/typecheck.yml | 1 + .github/workflows/typo.yml | 1 + 4 files changed, 4 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index c5e478ea323..2f872d48ec8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - v1b1 pull_request: jobs: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 33e206a3310..a6c4c12d9ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - v1b1 pull_request: workflow_dispatch: diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 56179bca2cd..4e9737d0b3b 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - v1b1 pull_request: jobs: diff --git a/.github/workflows/typo.yml b/.github/workflows/typo.yml index a11b099c81a..3d147e129a2 100644 --- a/.github/workflows/typo.yml +++ b/.github/workflows/typo.yml @@ -7,6 +7,7 @@ on: push: branches: - main + - v1b1 pull_request: env: From e25431a806e562e46b1ec74cd5511018bd539943 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 26 Mar 2026 19:49:39 -0700 Subject: [PATCH 13/69] Add pumping capability and migrate vendor backends (#958) Co-authored-by: Claude Opus 4.6 (1M context) --- pylabrobot/agrowpumps/__init__.py | 1 + .../agrowpumps/agrowdosepump_backend.py | 206 ++++++++++++++++ pylabrobot/agrowpumps/agrowdosepump_tests.py | 76 ++++++ pylabrobot/capabilities/pumping/__init__.py | 5 + pylabrobot/capabilities/pumping/backend.py | 19 ++ .../capabilities/pumping/calibration.py | 176 ++++++++++++++ pylabrobot/capabilities/pumping/chatterbox.py | 18 ++ pylabrobot/capabilities/pumping/errors.py | 2 + pylabrobot/capabilities/pumping/pumping.py | 86 +++++++ .../capabilities/pumping/pumping_tests.py | 79 +++++++ pylabrobot/cole_parmer/__init__.py | 1 + pylabrobot/cole_parmer/masterflex_backend.py | 114 +++++++++ .../pumps/agrowpumps/agrowdosepump_backend.py | 207 +++------------- .../pumps/agrowpumps/agrowdosepump_tests.py | 50 ++-- pylabrobot/legacy/pumps/calibration.py | 220 +----------------- .../pumps/cole_parmer/masterflex_backend.py | 87 ++----- pylabrobot/legacy/pumps/pump.py | 87 +++---- pylabrobot/legacy/pumps/pumparray.py | 184 +++++++-------- 18 files changed, 976 insertions(+), 642 deletions(-) create mode 100644 pylabrobot/agrowpumps/__init__.py create mode 100644 pylabrobot/agrowpumps/agrowdosepump_backend.py create mode 100644 pylabrobot/agrowpumps/agrowdosepump_tests.py create mode 100644 pylabrobot/capabilities/pumping/__init__.py create mode 100644 pylabrobot/capabilities/pumping/backend.py create mode 100644 pylabrobot/capabilities/pumping/calibration.py create mode 100644 pylabrobot/capabilities/pumping/chatterbox.py create mode 100644 pylabrobot/capabilities/pumping/errors.py create mode 100644 pylabrobot/capabilities/pumping/pumping.py create mode 100644 pylabrobot/capabilities/pumping/pumping_tests.py create mode 100644 pylabrobot/cole_parmer/__init__.py create mode 100644 pylabrobot/cole_parmer/masterflex_backend.py diff --git a/pylabrobot/agrowpumps/__init__.py b/pylabrobot/agrowpumps/__init__.py new file mode 100644 index 00000000000..122f9c721d3 --- /dev/null +++ b/pylabrobot/agrowpumps/__init__.py @@ -0,0 +1 @@ +from .agrowdosepump_backend import AgrowChannelBackend, AgrowDosePumpArray, AgrowDriver diff --git a/pylabrobot/agrowpumps/agrowdosepump_backend.py b/pylabrobot/agrowpumps/agrowdosepump_backend.py new file mode 100644 index 00000000000..230c9b969e1 --- /dev/null +++ b/pylabrobot/agrowpumps/agrowdosepump_backend.py @@ -0,0 +1,206 @@ +import asyncio +import logging +import threading +import time +from typing import Dict, List, Optional, Union + +try: + from pymodbus.client import AsyncModbusSerialClient # type: ignore + + _MODBUS_IMPORT_ERROR = None +except ImportError as e: + AsyncModbusSerialClient = None # type: ignore + _MODBUS_IMPORT_ERROR = e + +from pylabrobot.capabilities.capability import Capability +from pylabrobot.capabilities.pumping.backend import PumpBackend +from pylabrobot.capabilities.pumping.calibration import PumpCalibration +from pylabrobot.capabilities.pumping.pumping import PumpingCapability +from pylabrobot.device import Device, Driver + +logger = logging.getLogger("pylabrobot") + + +class AgrowDriver(Driver): + """Modbus driver for Agrow dose pump arrays.""" + + def __init__(self, port: str, address: Union[int, str]): + super().__init__() + if _MODBUS_IMPORT_ERROR is not None: + raise RuntimeError( + "pymodbus is not installed. Install with: pip install pylabrobot[modbus]. " + f"Import error: {_MODBUS_IMPORT_ERROR}" + ) + if not isinstance(port, str): + raise ValueError("Port must be a string") + self.port = port + if address not in range(0, 256): + raise ValueError("Pump address out of range") + self.address = int(address) + self._keep_alive_thread: Optional[threading.Thread] = None + self._pump_index_to_address: Optional[Dict[int, int]] = None + self._modbus: Optional["AsyncModbusSerialClient"] = None + self._num_channels: Optional[int] = None + self._keep_alive_thread_active = False + + @property + def modbus(self) -> "AsyncModbusSerialClient": + if self._modbus is None: + raise RuntimeError("Modbus connection not established") + return self._modbus + + @property + def pump_index_to_address(self) -> Dict[int, int]: + if self._pump_index_to_address is None: + raise RuntimeError("Pump mappings not established") + return self._pump_index_to_address + + @property + def num_channels(self) -> int: + if self._num_channels is None: + raise RuntimeError("Number of channels not established") + return self._num_channels + + def _start_keep_alive_thread(self): + async def keep_alive(): + i = 0 + while self._keep_alive_thread_active: + time.sleep(0.1) + i += 1 + if i == 250: + await self.modbus.read_holding_registers(0, 1, unit=self.address) + i = 0 + + def manage_async_keep_alive(): + try: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(keep_alive()) + loop.close() + except Exception as e: + logger.error("Error in keep alive thread: %s", e) + + self._keep_alive_thread_active = True + self._keep_alive_thread = threading.Thread(target=manage_async_keep_alive, daemon=True) + self._keep_alive_thread.start() + + async def setup(self): + await self._setup_modbus() + register_return = await self.modbus.read_holding_registers(19, 2, unit=self.address) + self._num_channels = int( + "".join(chr(r // 256) + chr(r % 256) for r in register_return.registers)[2] + ) + self._start_keep_alive_thread() + self._pump_index_to_address = {pump: pump + 100 for pump in range(0, self.num_channels)} + + async def _setup_modbus(self): + if AsyncModbusSerialClient is None: + raise RuntimeError( + "pymodbus is not installed. Install with: pip install pylabrobot[modbus]." + f" Import error: {_MODBUS_IMPORT_ERROR}" + ) + self._modbus = AsyncModbusSerialClient( + port=self.port, + baudrate=115200, + timeout=1, + stopbits=1, + bytesize=8, + parity="E", + retry_on_empty=True, + ) + await self.modbus.connect() + if not self.modbus.connected: + raise ConnectionError("Modbus connection failed during pump setup") + + async def stop(self): + for pump in self.pump_index_to_address: + await self.write_speed(pump, 0) + if self._keep_alive_thread is not None: + self._keep_alive_thread_active = False + self._keep_alive_thread.join() + self.modbus.close() + assert not self.modbus.connected, "Modbus failing to disconnect" + + async def write_speed(self, channel: int, speed: int): + if speed not in range(101): + raise ValueError("Pump speed out of range. Value should be between 0 and 100.") + await self.modbus.write_register( + self.pump_index_to_address[channel], + speed, + unit=self.address, + ) + + +class AgrowChannelBackend(PumpBackend): + """Per-channel PumpBackend adapter that delegates to a shared AgrowDriver.""" + + def __init__(self, connection: AgrowDriver, channel: int): + self._driver = connection + self._channel = channel + + async def run_revolutions(self, num_revolutions: float): + raise NotImplementedError( + "Revolution based pumping commands are not available for Agrow pumps." + ) + + async def run_continuously(self, speed: float): + await self._driver.write_speed(self._channel, int(speed)) + + async def halt(self): + await self._driver.write_speed(self._channel, 0) + + def serialize(self): + return { + "port": self._driver.port, + "address": self._driver.address, + "channel": self._channel, + } + + +class AgrowDosePumpArray(Device): + """Agrow dose pump array device. + + Exposes each channel as an individual PumpingCapability via `self.pumps`. + """ + + def __init__( + self, + port: str, + address: Union[int, str], + calibrations: Optional[List[Optional[PumpCalibration]]] = None, + ): + self._channel_backends: List[AgrowChannelBackend] = [] + self.pumps: List[PumpingCapability] = [] + self._calibrations = calibrations + super().__init__(driver=AgrowDriver(port=port, address=address)) + self._driver: AgrowDriver + + async def setup(self): + await self._driver.setup() + num_channels = self._driver.num_channels + + self._channel_backends = [AgrowChannelBackend(self._driver, ch) for ch in range(num_channels)] + self.pumps = [] + for i, backend in enumerate(self._channel_backends): + cal = None + if self._calibrations is not None and i < len(self._calibrations): + cal = self._calibrations[i] + cap = PumpingCapability(backend=backend, calibration=cal) + self.pumps.append(cap) + + self._capabilities: List[Capability] = list(self.pumps) + for c in self._capabilities: + await c._on_setup() + self._setup_finished = True + + async def stop(self): + for cap in reversed(self._capabilities): + await cap._on_stop() + await self._driver.stop() + self._setup_finished = False + + def serialize(self): + return { + "port": self._driver.port, + "address": self._driver.address, + } diff --git a/pylabrobot/agrowpumps/agrowdosepump_tests.py b/pylabrobot/agrowpumps/agrowdosepump_tests.py new file mode 100644 index 00000000000..3503185d47d --- /dev/null +++ b/pylabrobot/agrowpumps/agrowdosepump_tests.py @@ -0,0 +1,76 @@ +# mypy: disable-error-code="attr-defined,assignment" +import unittest +from unittest.mock import AsyncMock, patch + +import pytest + +pytest.importorskip("pymodbus") + +from pylabrobot.agrowpumps import AgrowDosePumpArray + + +class SimulatedModbusClient: + """Duck-typed modbus client for testing.""" + + def __init__(self): + self._connected = False + self.write_register = AsyncMock() + + async def connect(self): + self._connected = True + + @property + def connected(self): + return self._connected + + async def read_holding_registers(self, address: int, count: int, **kwargs): + if "unit" not in kwargs: + raise ValueError("unit must be specified") + if address == 19: + result = AsyncMock() + result.registers = [16708, 13824, 0, 0, 0, 0, 0][:count] + return result + + def close(self, reconnect=False): + self._connected = False + + +class TestAgrowPumps(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.device = AgrowDosePumpArray(port="simulated", address=1) + + async def _mock_setup_modbus(): + self.device._driver._modbus = SimulatedModbusClient() + + with patch.object(self.device._driver, "_setup_modbus", _mock_setup_modbus): + await self.device.setup() + + async def asyncTearDown(self): + await self.device.stop() + + async def test_setup(self): + self.assertEqual(self.device._driver.port, "simulated") + self.assertEqual(self.device._driver.address, 1) + self.assertEqual(len(self.device.pumps), 6) + self.assertEqual( + self.device._driver._pump_index_to_address, + {pump: pump + 100 for pump in range(0, 6)}, + ) + + async def test_run_continuously(self): + self.device._driver.modbus.write_register.reset_mock() + await self.device.pumps[0].run_continuously(speed=1) + self.device._driver.modbus.write_register.assert_called_once_with(100, 1, unit=1) + + # invalid speed: cannot be bigger than 100 + with self.assertRaises(ValueError): + await self.device.pumps[0].run_continuously(speed=101) + + async def test_run_revolutions(self): + with self.assertRaises(NotImplementedError): + await self.device.pumps[0].run_revolutions(num_revolutions=1.0) + + async def test_halt_single_channel(self): + self.device._driver.modbus.write_register.reset_mock() + await self.device.pumps[2].halt() + self.device._driver.modbus.write_register.assert_called_once_with(102, 0, unit=1) diff --git a/pylabrobot/capabilities/pumping/__init__.py b/pylabrobot/capabilities/pumping/__init__.py new file mode 100644 index 00000000000..4e3c3a32ec0 --- /dev/null +++ b/pylabrobot/capabilities/pumping/__init__.py @@ -0,0 +1,5 @@ +from .backend import PumpBackend +from .calibration import PumpCalibration +from .chatterbox import PumpChatterboxBackend +from .errors import NotCalibratedError +from .pumping import PumpingCapability diff --git a/pylabrobot/capabilities/pumping/backend.py b/pylabrobot/capabilities/pumping/backend.py new file mode 100644 index 00000000000..467d22977ce --- /dev/null +++ b/pylabrobot/capabilities/pumping/backend.py @@ -0,0 +1,19 @@ +from abc import ABCMeta, abstractmethod + +from pylabrobot.capabilities.capability import CapabilityBackend + + +class PumpBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for a single pump.""" + + @abstractmethod + async def run_revolutions(self, num_revolutions: float): + """Run for a given number of revolutions.""" + + @abstractmethod + async def run_continuously(self, speed: float): + """Run continuously at a given speed. If speed is 0, halt.""" + + @abstractmethod + async def halt(self): + """Halt the pump.""" diff --git a/pylabrobot/capabilities/pumping/calibration.py b/pylabrobot/capabilities/pumping/calibration.py new file mode 100644 index 00000000000..84c0d888c46 --- /dev/null +++ b/pylabrobot/capabilities/pumping/calibration.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +import csv +import json +from typing import Dict, List, Literal, Optional, Union + +from pylabrobot.serializer import SerializableMixin + + +class PumpCalibration(SerializableMixin): + """Calibration for a single pump or pump array + + Attributes: + calibration: The calibration of the pump or pump array. + """ + + def __init__( + self, + calibration: List[Union[float, int]], + calibration_mode: Literal["duration", "revolutions"] = "duration", + ): + """Initialize a PumpCalibration object. + + Args: + calibration: calibration of the pump in pump-specific volume per time/revolution units. + calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for + volume per revolution. Defaults to "duration". + + Raises: + ValueError: if a value in the calibration is outside expected parameters. + """ + + if any(value <= 0 for value in calibration): + raise ValueError("A value in the calibration is outside expected parameters.") + if calibration_mode not in ["duration", "revolutions"]: + raise ValueError("calibration_mode must be 'duration' or 'revolutions'") + self.calibration = calibration + self.calibration_mode = calibration_mode + + def __getitem__(self, item: int) -> Union[float, int]: + return self.calibration[item] # type: ignore + + def __len__(self) -> int: + """Return the length of the calibration.""" + return len(self.calibration) + + @classmethod + def load_calibration( + cls, + calibration: Optional[Union[dict, list, float, int, str]] = None, + num_items: Optional[int] = None, + calibration_mode: Literal["duration", "revolutions"] = "duration", + ) -> PumpCalibration: + """Load a calibration from a file, dictionary, list, or value. + + Args: + calibration: pump calibration file, dictionary, list, or value. + calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for + volume per revolution. Defaults to "duration". + num_items: number of items in the calibration. Required if calibration is a value. + + Raises: + NotImplementedError: if the calibration filetype or format is not supported. + ValueError: if num_items is not specified when calibration is a value. + """ + + if isinstance(calibration, dict): + return PumpCalibration.load_from_dict( + calibration=calibration, calibration_mode=calibration_mode + ) + if isinstance(calibration, list): + return PumpCalibration.load_from_list( + calibration=calibration, calibration_mode=calibration_mode + ) + if isinstance(calibration, (float, int)): + if num_items is None: + raise ValueError("num_items must be specified if calibration is a value.") + return PumpCalibration.load_from_value( + value=calibration, + num_items=num_items, + calibration_mode=calibration_mode, + ) + if isinstance(calibration, str): + if calibration.endswith(".json"): + return PumpCalibration.load_from_json( + file_path=calibration, calibration_mode=calibration_mode + ) + if calibration.endswith(".csv"): + return PumpCalibration.load_from_csv( + file_path=calibration, calibration_mode=calibration_mode + ) + raise NotImplementedError("Calibration filetype not supported.") + raise NotImplementedError("Calibration format not supported.") + + def serialize(self) -> dict: + return { + "calibration": self.calibration, + "calibration_mode": self.calibration_mode, + } + + @classmethod + def deserialize(cls, data: dict) -> PumpCalibration: + return cls( + calibration=data["calibration"], + calibration_mode=data["calibration_mode"], + ) + + @classmethod + def load_from_json( + cls, + file_path: str, + calibration_mode: Literal["duration", "revolutions"] = "duration", + ) -> PumpCalibration: + """Load a calibration from a json file.""" + + with open(file_path, "rb") as f: + calibration = json.load(f) + if isinstance(calibration, dict): + calibration = {int(key): float(value) for key, value in calibration.items()} + return PumpCalibration.load_from_dict( + calibration=calibration, calibration_mode=calibration_mode + ) + if isinstance(calibration, list): + return PumpCalibration(calibration=calibration, calibration_mode=calibration_mode) + raise TypeError(f"Calibration pulled from {file_path} is not a dictionary or list.") + + @classmethod + def load_from_csv( + cls, + file_path: str, + calibration_mode: Literal["duration", "revolutions"] = "duration", + ) -> PumpCalibration: + """Load a calibration from a csv file.""" + + with open(file_path, encoding="utf-8", newline="") as f: + csv_file = list(csv.reader(f)) + num_columns = len(csv_file[0]) + if num_columns != 2: + raise ValueError("CSV file must have two columns.") + calibration = {int(row[0]): float(row[1]) for row in csv_file} + return PumpCalibration.load_from_dict( + calibration=calibration, calibration_mode=calibration_mode + ) + + @classmethod + def load_from_dict( + cls, + calibration: Dict[int, Union[int, float]], + calibration_mode: Literal["duration", "revolutions"] = "duration", + ) -> PumpCalibration: + """Load a calibration from a dictionary (0-indexed).""" + + if sorted(calibration.keys()) != list(range(len(calibration))): + raise ValueError("Keys must be a contiguous range of integers starting at 0.") + calibration_list = [calibration[key] for key in sorted(calibration.keys())] + return cls(calibration=calibration_list, calibration_mode=calibration_mode) + + @classmethod + def load_from_list( + cls, + calibration: List[Union[int, float]], + calibration_mode: Literal["duration", "revolutions"] = "duration", + ) -> PumpCalibration: + """Load a calibration from a list.""" + return cls(calibration=calibration, calibration_mode=calibration_mode) + + @classmethod + def load_from_value( + cls, + value: Union[float, int], + num_items: int, + calibration_mode: Literal["duration", "revolutions"] = "duration", + ) -> PumpCalibration: + """Load a calibration from a single value applied to all channels.""" + calibration = [value] * num_items + return cls(calibration, calibration_mode) diff --git a/pylabrobot/capabilities/pumping/chatterbox.py b/pylabrobot/capabilities/pumping/chatterbox.py new file mode 100644 index 00000000000..efdf806ff70 --- /dev/null +++ b/pylabrobot/capabilities/pumping/chatterbox.py @@ -0,0 +1,18 @@ +import logging + +from .backend import PumpBackend + +logger = logging.getLogger(__name__) + + +class PumpChatterboxBackend(PumpBackend): + """Chatterbox backend for device-free testing.""" + + async def run_revolutions(self, num_revolutions: float): + logger.info("Running %s revolutions.", num_revolutions) + + async def run_continuously(self, speed: float): + logger.info("Running continuously at speed %s.", speed) + + async def halt(self): + logger.info("Halting the pump.") diff --git a/pylabrobot/capabilities/pumping/errors.py b/pylabrobot/capabilities/pumping/errors.py new file mode 100644 index 00000000000..64f19783fca --- /dev/null +++ b/pylabrobot/capabilities/pumping/errors.py @@ -0,0 +1,2 @@ +class NotCalibratedError(Exception): + """Error raised when calling a method that requires the pump to be calibrated.""" diff --git a/pylabrobot/capabilities/pumping/pumping.py b/pylabrobot/capabilities/pumping/pumping.py new file mode 100644 index 00000000000..fabbcc3be33 --- /dev/null +++ b/pylabrobot/capabilities/pumping/pumping.py @@ -0,0 +1,86 @@ +import asyncio +from typing import Optional, Union + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.capabilities.pumping.errors import NotCalibratedError + +from .backend import PumpBackend +from .calibration import PumpCalibration + + +class PumpingCapability(Capability): + """Single-pump capability.""" + + def __init__( + self, + backend: PumpBackend, + calibration: Optional[PumpCalibration] = None, + ): + super().__init__(backend=backend) + self.backend: PumpBackend = backend + if calibration is not None and len(calibration) != 1: + raise ValueError("Calibration may only have a single item for this pump") + self.calibration = calibration + + @need_capability_ready + async def run_revolutions(self, num_revolutions: float): + """Run for a given number of revolutions. + + Args: + num_revolutions: number of revolutions to run. + """ + await self.backend.run_revolutions(num_revolutions=num_revolutions) + + @need_capability_ready + async def run_continuously(self, speed: float): + """Run continuously at a given speed. If speed is 0, the pump will be halted. + + Args: + speed: speed in rpm/pump-specific units. + """ + await self.backend.run_continuously(speed=speed) + + @need_capability_ready + async def run_for_duration(self, speed: Union[float, int], duration: Union[float, int]): + """Run the pump at specified speed for the specified duration. + + Args: + speed: speed in rpm/pump-specific units. + duration: duration in seconds. + """ + if duration < 0: + raise ValueError("Duration must be positive.") + await self.run_continuously(speed=speed) + await asyncio.sleep(duration) + await self.run_continuously(speed=0) + + @need_capability_ready + async def pump_volume(self, speed: Union[float, int], volume: Union[float, int]): + """Run the pump at specified speed for the specified volume. Requires calibration. + + Args: + speed: speed in rpm/pump-specific units. + volume: volume to pump. + """ + if self.calibration is None: + raise NotCalibratedError( + "Pump is not calibrated. Volume based pumping and related functions unavailable." + ) + if self.calibration.calibration_mode == "duration": + duration = volume / self.calibration[0] + await self.run_for_duration(speed=speed, duration=duration) + elif self.calibration.calibration_mode == "revolutions": + num_revolutions = volume / self.calibration[0] + await self.run_revolutions(num_revolutions=num_revolutions) + else: + raise ValueError("Calibration mode not recognized.") + + @need_capability_ready + async def halt(self): + """Halt the pump.""" + await self.backend.halt() + + async def _on_stop(self): + if self._setup_finished: + await self.backend.halt() + await super()._on_stop() diff --git a/pylabrobot/capabilities/pumping/pumping_tests.py b/pylabrobot/capabilities/pumping/pumping_tests.py new file mode 100644 index 00000000000..144e506bca5 --- /dev/null +++ b/pylabrobot/capabilities/pumping/pumping_tests.py @@ -0,0 +1,79 @@ +import unittest +from unittest.mock import AsyncMock, Mock + +from pylabrobot.capabilities.pumping.backend import PumpBackend +from pylabrobot.capabilities.pumping.calibration import PumpCalibration +from pylabrobot.capabilities.pumping.errors import NotCalibratedError +from pylabrobot.capabilities.pumping.pumping import PumpingCapability + + +class TestPumpingCapability(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.mock_backend = Mock(spec=PumpBackend) + self.mock_backend.run_revolutions = AsyncMock() + self.mock_backend.run_continuously = AsyncMock() + self.mock_backend.halt = AsyncMock() + self.test_calibration = PumpCalibration.load_calibration(1, num_items=1) + + async def _make_cap(self, calibration=None): + cap = PumpingCapability(backend=self.mock_backend, calibration=calibration) + await cap._on_setup() + return cap + + async def test_setup(self): + cap = await self._make_cap() + self.assertIsNone(cap.calibration) + self.assertTrue(cap.setup_finished) + + async def test_run_revolutions(self): + cap = await self._make_cap() + await cap.run_revolutions(num_revolutions=1) + self.mock_backend.run_revolutions.assert_called_once_with(num_revolutions=1) + + async def test_run_continuously(self): + cap = await self._make_cap() + await cap.run_continuously(speed=100) + self.mock_backend.run_continuously.assert_called_once_with(speed=100) + + async def test_halt(self): + cap = await self._make_cap() + await cap.halt() + self.mock_backend.halt.assert_called_once() + + async def test_run_for_duration(self): + cap = await self._make_cap() + await cap.run_for_duration(speed=1, duration=0) + self.mock_backend.run_continuously.assert_called_with(speed=0) + + async def test_run_invalid_duration(self): + cap = await self._make_cap() + with self.assertRaises(ValueError): + await cap.run_for_duration(speed=1, duration=-1) + + async def test_pump_volume_duration_mode(self): + cap = await self._make_cap(calibration=self.test_calibration) + cap.calibration.calibration_mode = "duration" + cap.run_for_duration = AsyncMock() + await cap.pump_volume(speed=1, volume=1) + cap.run_for_duration.assert_called_once_with(speed=1, duration=1.0) + + async def test_pump_volume_revolutions_mode(self): + cap = await self._make_cap(calibration=self.test_calibration) + cap.calibration.calibration_mode = "revolutions" + cap.run_revolutions = AsyncMock() + await cap.pump_volume(speed=1, volume=1) + cap.run_revolutions.assert_called_once_with(num_revolutions=1.0) + + async def test_pump_volume_no_calibration(self): + cap = await self._make_cap() + with self.assertRaises(NotCalibratedError): + await cap.pump_volume(speed=1, volume=1) + + async def test_not_setup_raises(self): + cap = PumpingCapability(backend=self.mock_backend) + with self.assertRaises(RuntimeError): + await cap.run_continuously(speed=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/cole_parmer/__init__.py b/pylabrobot/cole_parmer/__init__.py new file mode 100644 index 00000000000..308c97ae35a --- /dev/null +++ b/pylabrobot/cole_parmer/__init__.py @@ -0,0 +1 @@ +from .masterflex_backend import MasterflexBackend, MasterflexDriver, MasterflexPump diff --git a/pylabrobot/cole_parmer/masterflex_backend.py b/pylabrobot/cole_parmer/masterflex_backend.py new file mode 100644 index 00000000000..6c0ab10f407 --- /dev/null +++ b/pylabrobot/cole_parmer/masterflex_backend.py @@ -0,0 +1,114 @@ +try: + import serial # type: ignore + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e + +from typing import Optional + +from pylabrobot.capabilities.pumping.backend import PumpBackend +from pylabrobot.capabilities.pumping.calibration import PumpCalibration +from pylabrobot.capabilities.pumping.pumping import PumpingCapability +from pylabrobot.device import Device, Driver +from pylabrobot.io.serial import Serial + + +class MasterflexDriver(Driver): + """Serial driver for Cole Parmer Masterflex L/S pumps. + + tested on: + 07551-20 + + should be same as: + 07522-20 + 07522-30 + 07551-30 + 07575-30 + 07575-40 + + Documentation available at: + - https://pim-resources.coleparmer.com/instruction-manual/a-1299-1127b-en.pdf + - https://web.archive.org/web/20210924061132/https://pim-resources.coleparmer.com/ + instruction-manual/a-1299-1127b-en.pdf + """ + + def __init__(self, com_port: str): + super().__init__() + if not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) + self.com_port = com_port + self.io = Serial( + port=self.com_port, + baudrate=4800, + timeout=1, + parity=serial.PARITY_ODD, + stopbits=serial.STOPBITS_ONE, + bytesize=serial.SEVENBITS, + human_readable_device_name="Masterflex Pump", + ) + + async def setup(self): + await self.io.setup() + await self.io.write(b"\x05") # Enquiry; ready to send. + await self.io.write(b"\x05P02\r") + + async def stop(self): + await self.io.stop() + + async def send_command(self, command: str): + command = "\x02P02" + command + "\x0d" + await self.io.write(command.encode()) + return await self.io.read() + + def serialize(self): + return {"type": self.__class__.__name__, "com_port": self.com_port} + + +class MasterflexBackend(PumpBackend): + """Pump capability backend for Masterflex L/S pumps.""" + + def __init__(self, driver: MasterflexDriver): + self._driver = driver + + async def run_revolutions(self, num_revolutions: float): + num_revolutions = round(num_revolutions, 2) + cmd = f"V{num_revolutions}G" + await self._driver.send_command(cmd) + + async def run_continuously(self, speed: float): + if speed == 0: + await self.halt() + return + + direction = "+" if speed > 0 else "-" + speed_int = int(abs(speed)) + cmd = f"S{direction}{speed_int}G0" + await self._driver.send_command(cmd) + + async def halt(self): + await self._driver.send_command("H") + + def serialize(self): + return { + "com_port": self._driver.com_port, + } + + +class MasterflexPump(Device): + """Cole Parmer Masterflex L/S pump.""" + + def __init__( + self, + com_port: str, + calibration: Optional[PumpCalibration] = None, + ): + driver = MasterflexDriver(com_port=com_port) + super().__init__(driver=driver) + self._driver: MasterflexDriver + self.pumping = PumpingCapability(backend=MasterflexBackend(driver), calibration=calibration) + self._capabilities = [self.pumping] diff --git a/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py index 605d835a542..01d75aebbc5 100644 --- a/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py +++ b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py @@ -1,146 +1,47 @@ -import asyncio -import logging -import threading -import time -from typing import Dict, List, Optional, Union +"""Legacy. Use pylabrobot.agrowpumps instead.""" -try: - from pymodbus.client import AsyncModbusSerialClient # type: ignore - - _MODBUS_IMPORT_ERROR = None -except ImportError as e: - AsyncModbusSerialClient = None # type: ignore - _MODBUS_IMPORT_ERROR = e +from typing import Dict, List, Union +from pylabrobot.agrowpumps.agrowdosepump_backend import AgrowChannelBackend, AgrowDriver from pylabrobot.legacy.pumps.backend import PumpArrayBackend -logger = logging.getLogger("pylabrobot") - class AgrowPumpArrayBackend(PumpArrayBackend): - """ - AgrowPumpArray allows users to control AgrowPumps via Modbus communication. - - https://www.agrowtek.com/doc/im/IM_MODBUS.pdf - https://agrowtek.com/doc/im/IM_LX1.pdf - - Attributes: - port: The port that the AgrowPumpArray is connected to. - address: The address of the AgrowPumpArray client registers. - - Properties: - num_channels: The number of channels that the AgrowPumpArray has. - pump_index_to_address: A dictionary that maps pump indices to their Modbus addresses. - """ + """Legacy. Use pylabrobot.agrowpumps.AgrowDosePumpArray instead.""" def __init__(self, port: str, address: Union[int, str]): - if _MODBUS_IMPORT_ERROR is not None: - raise RuntimeError( - "pymodbus is not installed. Install with: pip install pylabrobot[modbus]. " - f"Import error: {_MODBUS_IMPORT_ERROR}" - ) - if not isinstance(port, str): - raise ValueError("Port must be a string") - self.port = port - if address not in range(0, 256): - raise ValueError("Pump address out of range") - self.address = int(address) - self._keep_alive_thread: Optional[threading.Thread] = None - self._pump_index_to_address: Optional[Dict[int, int]] = None - self._modbus: Optional["AsyncModbusSerialClient"] = None - self._num_channels: Optional[int] = None - self._keep_alive_thread_active = False + self._driver = AgrowDriver(port=port, address=address) + self._backends: List[AgrowChannelBackend] = [] @property - def modbus(self) -> "AsyncModbusSerialClient": - """Returns the Modbus connection to the AgrowPumpArray.""" - if self._modbus is None: - raise RuntimeError("Modbus connection not established") - return self._modbus + def port(self): + return self._driver.port @property - def pump_index_to_address(self) -> Dict[int, int]: - """Returns a dictionary that maps pump indices to their Modbus addresses. + def address(self): + return self._driver.address - Returns: - Dict[int, int]: A dictionary that maps pump indices to their Modbus addresses. - """ - - if self._pump_index_to_address is None: - raise RuntimeError("Pump mappings not established") - return self._pump_index_to_address + @property + def modbus(self): + return self._driver.modbus @property def num_channels(self) -> int: - """The number of channels that the AgrowPumpArray has. - - Returns: - The number of channels that the AgrowPumpArray has. - """ - if self._num_channels is None: - raise RuntimeError("Number of channels not established") - return self._num_channels - - def start_keep_alive_thread(self): - """Creates a daemon thread that sends a Modbus request every 25 seconds to keep the connection - alive.""" - - async def keep_alive(): - """Sends a Modbus request every 25 seconds to keep the connection alive. - Sleep for 0.1 seconds so we can respond to `stop` events fast. - """ - i = 0 - while self._keep_alive_thread_active: - time.sleep(0.1) - i += 1 - if i == 250: - await self.modbus.read_holding_registers(0, 1, unit=self.address) - i = 0 - - def manage_async_keep_alive(): - """Manages the keep alive thread.""" - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete(keep_alive()) - loop.close() - except Exception as e: - logger.error("Error in keep alive thread: %s", e) - - self._keep_alive_thread_active = True - self._keep_alive_thread = threading.Thread(target=manage_async_keep_alive, daemon=True) - self._keep_alive_thread.start() + return self._driver.num_channels + + @property + def pump_index_to_address(self) -> Dict[int, int]: + return self._driver.pump_index_to_address async def setup(self): - """Sets up the Modbus connection to the AgrowPumpArray and creates the - pump mappings needed to issue commands. - """ - await self._setup_modbus() - register_return = await self.modbus.read_holding_registers(19, 2, unit=self.address) - self._num_channels = int( - "".join(chr(r // 256) + chr(r % 256) for r in register_return.registers)[2] - ) - self.start_keep_alive_thread() - self._pump_index_to_address = {pump: pump + 100 for pump in range(0, self.num_channels)} - - async def _setup_modbus(self): - if AsyncModbusSerialClient is None: - raise RuntimeError( - "pymodbus is not installed. Install with: pip install pylabrobot[modbus]." - f" Import error: {_MODBUS_IMPORT_ERROR}" - ) - self._modbus = AsyncModbusSerialClient( - port=self.port, - baudrate=115200, - timeout=1, - stopbits=1, - bytesize=8, - parity="E", - retry_on_empty=True, - ) - await self.modbus.connect() - if not self.modbus.connected: - raise ConnectionError("Modbus connection failed during pump setup") + await self._driver.setup() + self._backends = [ + AgrowChannelBackend(self._driver, ch) for ch in range(self._driver.num_channels) + ] + + async def stop(self): + await self.halt() + await self._driver.stop() def serialize(self): return { @@ -150,66 +51,20 @@ def serialize(self): } async def run_revolutions(self, num_revolutions: List[float], use_channels: List[int]): - """Run the specified channels at the speed selected. If speed is 0, the pump will be halted. - - Args: - num_revolutions: number of revolutions to run pumps. - use_channels: pump array channels to run - - Raises: - NotImplementedError: Revolution based pumping commands are not available for this array. - """ - raise NotImplementedError( "Revolution based pumping commands are not available for this pump array." ) async def run_continuously(self, speed: List[float], use_channels: List[int]): - """Run pumps at the specified speeds. - - Args: - speed: rate at which to run pump. - use_channels: pump array channels to run - - Raises: - ValueError: Pump address out of range - ValueError: Pump speed out of range - """ - - for pump_index, pump_speed in zip(use_channels, speed): - pump_speed = int(pump_speed) - if pump_speed not in range(101): - raise ValueError("Pump speed out of range. Value should be between 0 and 100.") - await self.modbus.write_register( - self.pump_index_to_address[pump_index], - pump_speed, - unit=self.address, - ) + for channel, pump_speed in zip(use_channels, speed): + await self._backends[channel].run_continuously(pump_speed) async def halt(self): - """Halt the entire pump array.""" - assert self.modbus is not None, "Modbus connection not established" - assert self.pump_index_to_address is not None, "Pump address mapping not established" - logger.info("Halting pump array") - for pump in self.pump_index_to_address: - address = self.pump_index_to_address[pump] - await self.modbus.write_register(address, 0, unit=self.address) - - async def stop(self): - """Close the connection to the pump array.""" - await self.halt() - assert self.modbus is not None, "Modbus connection not established" - if self._keep_alive_thread is not None: - self._keep_alive_thread_active = False - self._keep_alive_thread.join() - self.modbus.close() - assert not self.modbus.connected, "Modbus failing to disconnect" - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 + for backend in self._backends: + await backend.halt() +# Deprecated alias class AgrowPumpArray: def __init__(self, *args, **kwargs): raise RuntimeError( diff --git a/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py index 4a46ba87987..546506a2b09 100644 --- a/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py +++ b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py @@ -1,26 +1,21 @@ +# mypy: disable-error-code="attr-defined,assignment" import unittest -from unittest.mock import AsyncMock, call +from unittest.mock import AsyncMock, call, patch import pytest pytest.importorskip("pymodbus") -from pymodbus.client import AsyncModbusSerialClient # type: ignore - from pylabrobot.legacy.pumps import PumpArray from pylabrobot.legacy.pumps.agrowpumps import AgrowPumpArrayBackend -class SimulatedModbusClient(AsyncModbusSerialClient): - """ - SimulatedModbusClient allows users to simulate Modbus communication. - - Attributes: - connected: A boolean that indicates whether the simulated client is connected. - """ +class SimulatedModbusClient: + """Duck-typed modbus client for testing.""" - def __init__(self, connected: bool = False): - self._connected = connected + def __init__(self): + self._connected = False + self.write_register = AsyncMock() async def connect(self): self._connected = True @@ -29,35 +24,28 @@ async def connect(self): def connected(self): return self._connected - async def read_holding_registers(self, address: int, count: int, **kwargs): # type: ignore - """Simulates reading holding registers from the AgrowPumpArray.""" + async def read_holding_registers(self, address: int, count: int, **kwargs): if "unit" not in kwargs: raise ValueError("unit must be specified") if address == 19: - return_register = AsyncMock() - return_register.registers = [16708, 13824, 0, 0, 0, 0, 0][:count] - return return_register - - write_register = AsyncMock() + result = AsyncMock() + result.registers = [16708, 13824, 0, 0, 0, 0, 0][:count] + return result def close(self, reconnect=False): - assert not self.connected, "Modbus connection not established" self._connected = False class TestAgrowPumps(unittest.IsolatedAsyncioTestCase): - """TestAgrowPumps allows users to test AgrowPumps.""" - async def asyncSetUp(self): self.agrow_backend = AgrowPumpArrayBackend(port="simulated", address=1) async def _mock_setup_modbus(): - self.agrow_backend._modbus = SimulatedModbusClient() - - self.agrow_backend._setup_modbus = _mock_setup_modbus # type: ignore[method-assign] + self.agrow_backend._driver._modbus = SimulatedModbusClient() - self.pump_array = PumpArray(backend=self.agrow_backend, calibration=None) - await self.pump_array.setup() + with patch.object(self.agrow_backend._driver, "_setup_modbus", _mock_setup_modbus): + self.pump_array = PumpArray(backend=self.agrow_backend, calibration=None) + await self.pump_array.setup() async def asyncTearDown(self): await self.pump_array.stop() @@ -66,14 +54,14 @@ async def test_setup(self): self.assertEqual(self.agrow_backend.port, "simulated") self.assertEqual(self.agrow_backend.address, 1) self.assertEqual( - self.agrow_backend._pump_index_to_address, + self.agrow_backend.pump_index_to_address, {pump: pump + 100 for pump in range(0, 6)}, ) async def test_run_continuously(self): - self.agrow_backend.modbus.write_register.reset_mock() # type: ignore[attr-defined] + self.agrow_backend.modbus.write_register.reset_mock() await self.pump_array.run_continuously(speed=1, use_channels=[0]) - self.agrow_backend.modbus.write_register.assert_called_once_with(100, 1, unit=1) # type: ignore[attr-defined] + self.agrow_backend.modbus.write_register.assert_called_once_with(100, 1, unit=1) # invalid speed: cannot be bigger than 100 with self.assertRaises(ValueError): @@ -86,6 +74,6 @@ async def test_run_revolutions(self): async def test_halt(self): await self.pump_array.halt() - self.agrow_backend.modbus.write_register.assert_has_calls( # type: ignore[attr-defined] + self.agrow_backend.modbus.write_register.assert_has_calls( [call(100 + i, 0, unit=1) for i in range(6)] ) diff --git a/pylabrobot/legacy/pumps/calibration.py b/pylabrobot/legacy/pumps/calibration.py index 48a25afb6e8..82f3b8969b2 100644 --- a/pylabrobot/legacy/pumps/calibration.py +++ b/pylabrobot/legacy/pumps/calibration.py @@ -1,219 +1,5 @@ -from __future__ import annotations +"""Legacy. Use pylabrobot.capabilities.pumping.calibration instead.""" -import csv -import json -from typing import Dict, List, Literal, Optional, Union +from pylabrobot.capabilities.pumping.calibration import PumpCalibration -from pylabrobot.serializer import SerializableMixin - - -class PumpCalibration(SerializableMixin): - """Calibration for a single pump or pump array - - Attributes: - calibration: The calibration of the pump or pump array. - """ - - def __init__( - self, - calibration: List[Union[float, int]], - calibration_mode: Literal["duration", "revolutions"] = "duration", - ): - """Initialize a PumpCalibration object. - - Args: - calibration: calibration of the pump in pump-specific volume per time/revolution units. - calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for - volume per revolution. Defaults to "duration". - - Raises: - ValueError: if a value in the calibration is outside expected parameters. - """ - - if any(value <= 0 for value in calibration): - raise ValueError("A value in the calibration is is outside expected parameters.") - if calibration_mode not in ["duration", "revolutions"]: - raise ValueError("calibration_mode must be 'duration' or 'revolutions'") - self.calibration = calibration - self.calibration_mode = calibration_mode - - def __getitem__(self, item: int) -> Union[float, int]: - return self.calibration[item] # type: ignore - - def __len__(self) -> int: - """Return the length of the calibration.""" - - return len(self.calibration) - - @classmethod - def load_calibration( - cls, - calibration: Optional[Union[dict, list, float, int, str]] = None, - num_items: Optional[int] = None, - calibration_mode: Literal["duration", "revolutions"] = "duration", - ) -> PumpCalibration: - """Load a calibration from a file, dictionary, list, or value. :param calibration: pump - calibration file, dictionary, list, or value. If None, returns an empty PumpCalibration - object. - - Args: - calibration: pump calibration file, dictionary, list, or value. - calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for - volume per revolution. Defaults to "duration". - num_items: number of items in the calibration. Required if calibration is a value. - - Raises: - NotImplementedError: if the calibration filetype or format is not supported. - ValueError: if num_items is not specified when calibration is a value. - """ - - if isinstance(calibration, dict): - return PumpCalibration.load_from_dict( - calibration=calibration, calibration_mode=calibration_mode - ) - if isinstance(calibration, list): - return PumpCalibration.load_from_list( - calibration=calibration, calibration_mode=calibration_mode - ) - if isinstance(calibration, (float, int)): - if num_items is None: - raise ValueError("num_items must be specified if calibration is a value.") - return PumpCalibration.load_from_value( - value=calibration, - num_items=num_items, - calibration_mode=calibration_mode, - ) - if isinstance(calibration, str): - if calibration.endswith(".json"): - return PumpCalibration.load_from_json( - file_path=calibration, calibration_mode=calibration_mode - ) - if calibration.endswith(".csv"): - return PumpCalibration.load_from_csv( - file_path=calibration, calibration_mode=calibration_mode - ) - raise NotImplementedError("Calibration filetype not supported.") - raise NotImplementedError("Calibration format not supported.") - - def serialize(self) -> dict: - return { - "calibration": self.calibration, - "calibration_mode": self.calibration_mode, - } - - @classmethod - def deserialize(cls, data: dict) -> PumpCalibration: - return cls( - calibration=data["calibration"], - calibration_mode=data["calibration_mode"], - ) - - @classmethod - def load_from_json( - cls, - file_path: str, - calibration_mode: Literal["duration", "revolutions"] = "duration", - ) -> PumpCalibration: - """Load a calibration from a json file. - - Args: - file_path: json file to load calibration from. - calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for - volume per revolution. Defaults to "duration". - - Raises: - TypeError: if the calibration pulled from the json is not a dictionary or list. - """ - - with open(file_path, "rb") as f: - calibration = json.load(f) - if isinstance(calibration, dict): - calibration = {int(key): float(value) for key, value in calibration.items()} - return PumpCalibration.load_from_dict( - calibration=calibration, calibration_mode=calibration_mode - ) - if isinstance(calibration, list): - return PumpCalibration(calibration=calibration, calibration_mode=calibration_mode) - raise TypeError(f"Calibration pulled from {file_path} is not a dictionary or list.") - - @classmethod - def load_from_csv( - cls, - file_path: str, - calibration_mode: Literal["duration", "revolutions"] = "duration", - ) -> PumpCalibration: - """Load a calibration from a csv file. - - Args: - file_path: csv file to load calibration from. 0-indexed. The first column is treated as the - index, the second column as the value. - calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for - volume per revolution. Defaults to "duration". - """ - - with open(file_path, encoding="utf-8", newline="") as f: - csv_file = list(csv.reader(f)) - num_columns = len(csv_file[0]) - if num_columns != 2: - raise ValueError("CSV file must have two columns.") - calibration = {int(row[0]): float(row[1]) for row in csv_file} - return PumpCalibration.load_from_dict( - calibration=calibration, calibration_mode=calibration_mode - ) - - @classmethod - def load_from_dict( - cls, - calibration: Dict[int, Union[int, float]], - calibration_mode: Literal["duration", "revolutions"] = "duration", - ) -> PumpCalibration: - """Load a calibration from a dictionary. - - Args: - calibration: dictionary to load calibration from. 0-indexed. - calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for - volume per revolution. Defaults to "duration". - - Raises: - ValueError: if the calibration dictionary is not formatted correctly. - """ - - if sorted(calibration.keys()) != list(range(len(calibration))): - raise ValueError("Keys must be a contiguous range of integers starting at 0.") - calibration_list = [calibration[key] for key in sorted(calibration.keys())] - return cls(calibration=calibration_list, calibration_mode=calibration_mode) - - @classmethod - def load_from_list( - cls, - calibration: List[Union[int, float]], - calibration_mode: Literal["duration", "revolutions"] = "duration", - ) -> PumpCalibration: - """Load a calibration from a list. Equivalent to PumpCalibration(calibration). - - Args: - calibration: list to load calibration from. - calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for - volume per revolution. Defaults to "duration". - """ - - return cls(calibration=calibration, calibration_mode=calibration_mode) - - @classmethod - def load_from_value( - cls, - value: Union[float, int], - num_items: int, - calibration_mode: Literal["duration", "revolutions"] = "duration", - ) -> PumpCalibration: - """Load a calibration from a value. Equivalent to PumpCalibration([value] * num_items). - - Args: - value: value to load calibration from. - num_items: number of items in the calibration. - calibration_mode: units of the calibration. "duration" for volume per time, "revolutions" for - volume per revolution. Defaults to "duration". - """ - - calibration = [value] * num_items - return cls(calibration, calibration_mode) +__all__ = ["PumpCalibration"] diff --git a/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py b/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py index 9612f02a024..c7ca690b254 100644 --- a/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py +++ b/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py @@ -1,91 +1,48 @@ -try: - import serial # type: ignore +"""Legacy. Use pylabrobot.cole_parmer instead.""" - HAS_SERIAL = True -except ImportError as e: - HAS_SERIAL = False - _SERIAL_IMPORT_ERROR = e - -from pylabrobot.io.serial import Serial +from pylabrobot.cole_parmer.masterflex_backend import MasterflexBackend as _NewBackend +from pylabrobot.cole_parmer.masterflex_backend import MasterflexDriver from pylabrobot.legacy.pumps.backend import PumpBackend class MasterflexBackend(PumpBackend): - """Backend for the Cole Parmer Masterflex L/S pump + """Legacy. Use pylabrobot.cole_parmer.MasterflexBackend instead.""" - tested on: - 07551-20 + def __init__(self, com_port: str): + self._driver = MasterflexDriver(com_port=com_port) + self._backend = _NewBackend(self._driver) - should be same as: - 07522-20 - 07522-30 - 07551-30 - 07575-30 - 07575-40 + @property + def io(self): + return self._driver.io - Documentation available at: - - https://pim-resources.coleparmer.com/instruction-manual/a-1299-1127b-en.pdf - - https://web.archive.org/web/20210924061132/https://pim-resources.coleparmer.com/ - instruction-manual/a-1299-1127b-en.pdf - """ - - def __init__(self, com_port: str): - if not HAS_SERIAL: - raise RuntimeError( - "pyserial is not installed. Install with: pip install pylabrobot[serial]. " - f"Import error: {_SERIAL_IMPORT_ERROR}" - ) - self.com_port = com_port - self.io = Serial( - port=self.com_port, - baudrate=4800, - timeout=1, - parity=serial.PARITY_ODD, - stopbits=serial.STOPBITS_ONE, - bytesize=serial.SEVENBITS, - human_readable_device_name="Masterflex Pump", - ) + @io.setter + def io(self, value): + self._driver.io = value async def setup(self): - await self.io.setup() + await self._driver.setup() - await self.io.write(b"\x05") # Enquiry; ready to send. - await self.io.write(b"\x05P02\r") + async def stop(self): + await self._driver.stop() def serialize(self): - return {**super().serialize(), "com_port": self.com_port} - - async def stop(self): - await self.io.stop() + return {"type": self.__class__.__name__, "com_port": self._driver.com_port} async def send_command(self, command: str): - command = "\x02P02" + command + "\x0d" - await self.io.write(command.encode()) - return self.io.read() + return await self._driver.send_command(command) async def run_revolutions(self, num_revolutions: float): - num_revolutions = round(num_revolutions, 2) - cmd = f"V{num_revolutions}G" - await self.send_command(cmd) + await self._backend.run_revolutions(num_revolutions) async def run_continuously(self, speed: float): - if speed == 0: - self.halt() - return - - direction = "+" if speed > 0 else "-" - speed = int(abs(speed)) - cmd = f"S{direction}{speed}G0" - await self.send_command(cmd) + await self._backend.run_continuously(speed) async def halt(self): - await self.send_command("H") - - -# Deprecated alias with warning # TODO: remove mid May 2025 (giving people 1 month to update) -# https://github.com/PyLabRobot/pylabrobot/issues/466 + await self._backend.halt() +# Deprecated alias class Masterflex: def __init__(self, *args, **kwargs): raise RuntimeError("`Masterflex` is deprecated. Please use `MasterflexBackend` instead.") diff --git a/pylabrobot/legacy/pumps/pump.py b/pylabrobot/legacy/pumps/pump.py index 5b9d3b31670..1f28c81765e 100644 --- a/pylabrobot/legacy/pumps/pump.py +++ b/pylabrobot/legacy/pumps/pump.py @@ -1,12 +1,29 @@ -import asyncio from typing import Optional, Union +from pylabrobot.capabilities.pumping.backend import PumpBackend as _NewPumpBackend +from pylabrobot.capabilities.pumping.pumping import PumpingCapability from pylabrobot.legacy.machines.machine import Machine from .backend import PumpBackend from .calibration import PumpCalibration +class _PumpAdapter(_NewPumpBackend): + """Adapts a legacy PumpBackend to the new PumpBackend (CapabilityBackend).""" + + def __init__(self, legacy: PumpBackend): + self._legacy = legacy + + async def run_revolutions(self, num_revolutions: float): + self._legacy.run_revolutions(num_revolutions=num_revolutions) + + async def run_continuously(self, speed: float): + self._legacy.run_continuously(speed=speed) + + async def halt(self): + self._legacy.halt() + + class Pump(Machine): """Frontend for a (peristaltic) pump.""" @@ -16,10 +33,19 @@ def __init__( calibration: Optional[PumpCalibration] = None, ): super().__init__(backend=backend) - self.backend: PumpBackend = backend # fix type + self.backend: PumpBackend = backend if calibration is not None and len(calibration) != 1: raise ValueError("Calibration may only have a single item for this pump") self.calibration = calibration + self._pumping = PumpingCapability(backend=_PumpAdapter(backend), calibration=calibration) + + async def setup(self, **backend_kwargs): + await super().setup(**backend_kwargs) + await self._pumping._on_setup() + + async def stop(self): + await self._pumping._on_stop() + await super().stop() def serialize(self) -> dict: if self.calibration is None: @@ -39,63 +65,16 @@ def deserialize(cls, data: dict): return super().deserialize(data_copy) async def run_revolutions(self, num_revolutions: float): - """Run a given number of revolutions. This method will return after the command has been sent, - and the pump will run until `halt` is called. - - Args: - num_revolutions: number of revolutions to run - """ - - self.backend.run_revolutions(num_revolutions=num_revolutions) + await self._pumping.run_revolutions(num_revolutions=num_revolutions) async def run_continuously(self, speed: float): - """Run continuously at a given speed. This method will return after the command has been sent, - and the pump will run until `halt` is called. - - If speed is 0, the pump will be halted. - - Args: - speed: speed in rpm/pump-specific units. - """ - - self.backend.run_continuously(speed=speed) + await self._pumping.run_continuously(speed=speed) async def run_for_duration(self, speed: Union[float, int], duration: Union[float, int]): - """Run the pump at specified speed for the specified duration. - - Args: - speed: speed in rpm/pump-specific units. - duration: duration to run pump. - """ - - if duration < 0: - raise ValueError("Duration must be positive.") - await self.run_continuously(speed=speed) - await asyncio.sleep(duration) - await self.run_continuously(speed=0) + await self._pumping.run_for_duration(speed=speed, duration=duration) async def pump_volume(self, speed: Union[float, int], volume: Union[float, int]): - """Run the pump at specified speed for the specified volume. Note that this function requires - the pump to be calibrated at the input speed. - - Args: - speed: speed in rpm/pump-specific units. - volume: volume to pump. - """ - - if self.calibration is None: - raise TypeError( - "Pump is not calibrated. Volume based pumping and related functions unavailable." - ) - if self.calibration.calibration_mode == "duration": - duration = volume / self.calibration[0] - await self.run_for_duration(speed=speed, duration=duration) - elif self.calibration.calibration_mode == "revolutions": - num_revolutions = volume / self.calibration[0] - await self.run_revolutions(num_revolutions=num_revolutions) - else: - raise ValueError("Calibration mode not recognized.") + await self._pumping.pump_volume(speed=speed, volume=volume) async def halt(self): - """Halt the pump.""" - self.backend.halt() + await self._pumping.halt() diff --git a/pylabrobot/legacy/pumps/pumparray.py b/pylabrobot/legacy/pumps/pumparray.py index 9d7ca1a5cec..af5d7ff1034 100644 --- a/pylabrobot/legacy/pumps/pumparray.py +++ b/pylabrobot/legacy/pumps/pumparray.py @@ -1,22 +1,35 @@ import asyncio from typing import List, Optional, Union +from pylabrobot.capabilities.pumping.backend import PumpBackend as _NewPumpBackend +from pylabrobot.capabilities.pumping.pumping import PumpingCapability from pylabrobot.legacy.machines.machine import Machine from pylabrobot.legacy.pumps.backend import PumpArrayBackend from pylabrobot.legacy.pumps.calibration import PumpCalibration from pylabrobot.legacy.pumps.errors import NotCalibratedError -class PumpArray(Machine): - """Front-end for a pump array. +class _ChannelAdapter(_NewPumpBackend): + """Adapts one channel of a legacy PumpArrayBackend to the new PumpBackend.""" + + def __init__(self, legacy: PumpArrayBackend, channel: int): + self._legacy = legacy + self._channel = channel - Attributes: - backend: The backend that the pump array is controlled through. - calibration: The calibration of the pump. + async def run_revolutions(self, num_revolutions: float): + await self._legacy.run_revolutions( + num_revolutions=[num_revolutions], use_channels=[self._channel] + ) - Properties: - num_channels: The number of channels that the pump array has. - """ + async def run_continuously(self, speed: float): + await self._legacy.run_continuously(speed=[speed], use_channels=[self._channel]) + + async def halt(self): + await self._legacy.run_continuously(speed=[0.0], use_channels=[self._channel]) + + +class PumpArray(Machine): + """Front-end for a pump array.""" def __init__( self, @@ -24,18 +37,27 @@ def __init__( calibration: Optional[PumpCalibration] = None, ): super().__init__(backend=backend) - self.backend: PumpArrayBackend = backend # fix type + self.backend: PumpArrayBackend = backend self.calibration = calibration + self._pumps: List[PumpingCapability] = [] @property def num_channels(self) -> int: - """Returns the number of channels that the pump array has. + return self.backend.num_channels - Returns: - int: The number of channels that the pump array has. - """ + async def setup(self, **backend_kwargs): + await super().setup(**backend_kwargs) + self._pumps = [ + PumpingCapability(backend=_ChannelAdapter(self.backend, ch)) + for ch in range(self.num_channels) + ] + for p in self._pumps: + await p._on_setup() - return self.backend.num_channels + async def stop(self): + for p in reversed(self._pumps): + await p._on_stop() + await super().stop() def serialize(self) -> dict: if self.calibration is None: @@ -54,61 +76,56 @@ def deserialize(cls, data: dict): data_copy["calibration"] = calibration return super().deserialize(data_copy) + # -- helpers ---------------------------------------------------------------- + + def _normalize_channels(self, use_channels: Union[int, List[int]]) -> List[int]: + if isinstance(use_channels, int): + use_channels = [use_channels] + if len(set(use_channels)) != len(use_channels): + raise ValueError("Channels in use channels must be unique.") + if any(ch not in range(0, self.num_channels) for ch in use_channels): + raise ValueError( + f"Pump address out of range for this pump array. " + f"Value should be between 0 and {self.num_channels - 1}" + ) + if any(ch < 0 for ch in use_channels): + raise ValueError("Channels in use channels must be positive.") + return use_channels + + @staticmethod + def _normalize_speeds(speed: Union[float, int, List[float], List[int]], n: int) -> List[float]: + if isinstance(speed, (float, int)): + speed = [float(speed)] * n + if any(s < 0 for s in speed): + raise ValueError("Speed must be positive.") + if len(speed) != n: + raise ValueError("Speed and use_channels must be the same length.") + return [float(s) for s in speed] + + # -- public API ------------------------------------------------------------- + async def run_revolutions( self, num_revolutions: Union[float, List[float]], use_channels: Union[int, List[int]], ): - """Run the specified channels for the specified number of revolutions. - - Args: - num_revolutions: number of revolutions to run pumps. - use_channels: pump array channels to run. - """ - - if isinstance(use_channels, int): - use_channels = [use_channels] - if isinstance(num_revolutions, float): - num_revolutions = [num_revolutions] * len(use_channels) - await self.backend.run_revolutions(num_revolutions=num_revolutions, use_channels=use_channels) + channels = self._normalize_channels(use_channels) + if isinstance(num_revolutions, (float, int)): + num_revolutions = [float(num_revolutions)] * len(channels) + if len(num_revolutions) != len(channels): + raise ValueError("num_revolutions and use_channels must be the same length.") + for ch, rev in zip(channels, num_revolutions): + await self._pumps[ch].run_revolutions(num_revolutions=rev) async def run_continuously( self, speed: Union[float, int, List[float], List[int]], use_channels: Union[int, List[int]], ): - """Run the specified channels at the specified speeds. - - Args: - speed: speed in rpm/pump-specific units. - use_channels: pump array channels to run. - """ - - if isinstance(use_channels, list) and len(set(use_channels)) != len(use_channels): - raise ValueError("Channels in use channels must be unique.") - if isinstance(use_channels, int): - use_channels = [use_channels] - if isinstance(speed, (float, int)): - speed = [speed] * len(use_channels) - - if any(channel not in range(0, self.num_channels) for channel in use_channels): - raise ValueError( - f"Pump address out of range for this pump array. \ - Value should be between 0 and {self.num_channels}" - ) - if any(speed < 0 for speed in speed): - raise ValueError("Speed must be positive.") - if isinstance(speed[0], int): - speed = [float(x) for x in speed] - if len(speed) != len(use_channels): - raise ValueError("Speed and use_channels must be the same length.") - if any(channel < 0 for channel in use_channels): - raise ValueError("Channels in use channels must be positive.") - - await self.backend.run_continuously( - speed=speed, # type: ignore[arg-type] - use_channels=use_channels, - ) + channels = self._normalize_channels(use_channels) + speeds = self._normalize_speeds(speed, len(channels)) + for ch, s in zip(channels, speeds): + await self._pumps[ch].run_continuously(speed=s) async def run_for_duration( self, @@ -116,14 +133,6 @@ async def run_for_duration( use_channels: Union[int, List[int]], duration: Union[float, int], ): - """Run the specified channels at the specified speeds for the specified duration. - - Args: - speed: speed in rpm/pump-specific units. - use_channels: pump array channels to run. - duration: duration to run pumps (seconds). - """ - if duration < 0: raise ValueError("Duration must be positive.") await self.run_continuously(speed=speed, use_channels=use_channels) @@ -136,58 +145,35 @@ async def pump_volume( use_channels: Union[int, List[int]], volume: Union[float, int, List[float], List[int]], ): - """Run the specified channels at the specified speeds for the specified volume. Note that this - function requires the pump to be calibrated at the input speed. - - Args: - speed: speed in rpm/pump-specific units. use_channels: pump array channels to run using - 0-index. volume: volume to pump. - calibration_mode: units of calibration. Volume per seconds ("duration") or volume per - revolution ("revolutions"). - - Raises: - NotCalibratedError: if the pump is not calibrated. - """ - if self.calibration is None: raise NotCalibratedError( "Pump is not calibrated. Volume based pumping and related functions unavailable." ) - if isinstance(use_channels, int): - use_channels = [use_channels] - if isinstance(speed, (float, int)): - speed = [speed] * len(use_channels) + channels = self._normalize_channels(use_channels) + speeds = self._normalize_speeds(speed, len(channels)) if isinstance(volume, (float, int)): - volume = [volume] * len(use_channels) + volume = [float(volume)] * len(channels) if not all(vol >= 0 for vol in volume): raise ValueError("Volume must be positive.") - if not len(speed) == len(use_channels) == len(volume): + if len(volume) != len(channels): raise ValueError("Speed, use_channels, and volume must be the same length.") if self.calibration.calibration_mode == "duration": durations = [ channel_volume / self.calibration[channel] - for channel, channel_volume in zip(use_channels, volume) + for channel, channel_volume in zip(channels, volume) ] tasks = [ - asyncio.create_task( - self.run_for_duration( - speed=channel_speed, - use_channels=channel, - duration=duration, - ) - ) - for channel_speed, channel, duration in zip(speed, use_channels, durations) + asyncio.create_task(self.run_for_duration(speed=s, use_channels=ch, duration=d)) + for s, ch, d in zip(speeds, channels, durations) ] elif self.calibration.calibration_mode == "revolutions": num_rotations = [ channel_volume / self.calibration[channel] - for channel, channel_volume in zip(use_channels, volume) + for channel, channel_volume in zip(channels, volume) ] tasks = [ - asyncio.create_task( - self.run_revolutions(num_revolutions=num_rotation, use_channels=channel) - ) - for num_rotation, channel in zip(num_rotations, use_channels) + asyncio.create_task(self.run_revolutions(num_revolutions=r, use_channels=ch)) + for r, ch in zip(num_rotations, channels) ] else: raise ValueError("Calibration mode must be 'duration' or 'revolutions'.") From ab4af3d8809e25454dec34ea0a20a9a8fb51807f Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 26 Mar 2026 21:44:34 -0700 Subject: [PATCH 14/69] Refactor BioShake models from subclasses to factory functions Move shaker/tc capabilities into base class with has_shaking, has_temperature, supports_active_cooling flags. Add resource definitions for BioShake3000, BioShake3000Elm, BioShake3000ElmDWP, and BioShakeQ1 from spec sheets. Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/qinstruments/bioshake.py | 325 +++++++++------------------- 1 file changed, 99 insertions(+), 226 deletions(-) diff --git a/pylabrobot/qinstruments/bioshake.py b/pylabrobot/qinstruments/bioshake.py index 2d4bb933410..b249fad6cfd 100644 --- a/pylabrobot/qinstruments/bioshake.py +++ b/pylabrobot/qinstruments/bioshake.py @@ -242,24 +242,28 @@ async def deactivate(self): class BioShake(PlateHolder, Device): - """QInstruments BioShake base class. + """QInstruments BioShake device. - Use a subclass for your specific model. Capabilities (``tc``, ``shaker``) - are only present on subclasses whose hardware supports them. + Use a model-specific factory function (e.g. ``BioShake3000``) to create instances. + ``shaker`` and ``tc`` are ``None`` when the hardware doesn't support the capability. """ def __init__( self, name: str, + port: str, size_x: float, size_y: float, size_z: float, - driver: BioShakeDriver, child_location: Coordinate, pedestal_size_z: float, + has_shaking: bool = False, + has_temperature: bool = False, + supports_active_cooling: bool = False, category: str = "bioshake", model: Optional[str] = None, ): + driver = BioShakeDriver(port=port) PlateHolder.__init__( self, name=name, @@ -274,6 +278,19 @@ def __init__( Device.__init__(self, driver=driver) self._driver: BioShakeDriver = driver + self.shaker: Optional[ShakingCapability] = None + self.tc: Optional[TemperatureControlCapability] = None + self._capabilities = [] + + if has_shaking: + self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) + self._capabilities.append(self.shaker) + if has_temperature: + self.tc = TemperatureControlCapability( + backend=BioShakeTemperatureBackend(driver, supports_active_cooling=supports_active_cooling) + ) + self._capabilities.append(self.tc) + def serialize(self) -> dict: return { **Device.serialize(self), @@ -281,253 +298,109 @@ def serialize(self) -> dict: } -# -- Per-model classes -- -# Shaking only +# -- Factory functions for specific models -- -class BioShake3000(BioShake): +def BioShake3000(name: str, port: str) -> BioShake: """BioShake 3000 - shaking 200-3000 rpm, no ELM, no heating.""" - - def __init__(self, name: str, port: str): - raise NotImplementedError("BioShake3000 is missing resource definition.") - driver = BioShakeDriver(port=port) - super().__init__( - name=name, - driver=driver, - size_x=0, - size_y=0, - size_z=0, # TODO - child_location=Coordinate(0, 0, 0), # TODO - pedestal_size_z=0, # TODO - ) - self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) - self._capabilities = [self.shaker] - - -class BioShake3000Elm(BioShake): + return BioShake( + name=name, + port=port, + size_x=142, # from spec + size_y=99, # from spec + size_z=60.67, # from spec + child_location=Coordinate(7.12, 6.76, 51.75), # from spec + pedestal_size_z=0, + has_shaking=True, + model=BioShake3000.__name__, + ) + + +def BioShake3000Elm(name: str, port: str) -> BioShake: """BioShake 3000 elm - shaking 200-3000 rpm, ELM, no heating.""" - - def __init__(self, name: str, port: str): - raise NotImplementedError("BioShake3000Elm is missing resource definition.") - driver = BioShakeDriver(port=port) - super().__init__( - name=name, - driver=driver, - size_x=0, - size_y=0, - size_z=0, # TODO - child_location=Coordinate(0, 0, 0), # TODO - pedestal_size_z=0, # TODO - ) - self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) - self._capabilities = [self.shaker] - - -class BioShake3000ElmDWP(BioShake): + return BioShake( + name=name, + port=port, + size_x=142, # from spec + size_y=99, # from spec + size_z=55.35, # from spec + child_location=Coordinate(7.12, 6.76, 48.20), # from spec + pedestal_size_z=0, + has_shaking=True, + model=BioShake3000Elm.__name__, + ) + + +def BioShake3000ElmDWP(name: str, port: str) -> BioShake: """BioShake 3000 elm DWP - shaking 200-3000 rpm, ELM, no heating.""" - - def __init__(self, name: str, port: str): - raise NotImplementedError("BioShake3000ElmDWP is missing resource definition.") - driver = BioShakeDriver(port=port) - super().__init__( - name=name, - driver=driver, - size_x=0, - size_y=0, - size_z=0, # TODO - child_location=Coordinate(0, 0, 0), # TODO - pedestal_size_z=0, # TODO - ) - self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) - self._capabilities = [self.shaker] - - -class BioShakeD30Elm(BioShake): + return BioShake( + name=name, + port=port, + size_x=142, # from spec + size_y=99, # from spec + size_z=55.35, # from spec + child_location=Coordinate(7.12, 6.76, 48.20), # from spec + pedestal_size_z=0, + has_shaking=True, + model=BioShake3000ElmDWP.__name__, + ) + + +def BioShakeD30Elm(name: str, port: str) -> BioShake: """BioShake D30 elm - shaking 200-2000 rpm, ELM, no heating.""" + raise NotImplementedError("BioShakeD30Elm is missing resource definition.") - def __init__(self, name: str, port: str): - raise NotImplementedError("BioShakeD30Elm is missing resource definition.") - driver = BioShakeDriver(port=port) - super().__init__( - name=name, - driver=driver, - size_x=0, - size_y=0, - size_z=0, # TODO - child_location=Coordinate(0, 0, 0), # TODO - pedestal_size_z=0, # TODO - ) - self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) - self._capabilities = [self.shaker] - -class BioShake5000Elm(BioShake): +def BioShake5000Elm(name: str, port: str) -> BioShake: """BioShake 5000 elm - shaking 200-5000 rpm, ELM, no heating.""" + raise NotImplementedError("BioShake5000Elm is missing resource definition.") - def __init__(self, name: str, port: str): - raise NotImplementedError("BioShake5000Elm is missing resource definition.") - driver = BioShakeDriver(port=port) - super().__init__( - name=name, - driver=driver, - size_x=0, - size_y=0, - size_z=0, # TODO - child_location=Coordinate(0, 0, 0), # TODO - pedestal_size_z=0, # TODO - ) - self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) - self._capabilities = [self.shaker] - - -# Shaking + heating (no active cooling) - -class BioShake3000T(BioShake): +def BioShake3000T(name: str, port: str) -> BioShake: """BioShake 3000-T - shaking 200-3000 rpm, no ELM, heating.""" + raise NotImplementedError("BioShake3000T is missing resource definition.") - def __init__(self, name: str, port: str): - raise NotImplementedError("BioShake3000T is missing resource definition.") - driver = BioShakeDriver(port=port) - super().__init__( - name=name, - driver=driver, - size_x=0, - size_y=0, - size_z=0, # TODO - child_location=Coordinate(0, 0, 0), # TODO - pedestal_size_z=0, # TODO - ) - self.tc = TemperatureControlCapability(backend=BioShakeTemperatureBackend(driver)) - self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) - self._capabilities = [self.tc, self.shaker] - -class BioShake3000TElm(BioShake): +def BioShake3000TElm(name: str, port: str) -> BioShake: """BioShake 3000-T elm - shaking 200-3000 rpm, ELM, heating.""" - - def __init__(self, name: str, port: str): - raise NotImplementedError("BioShake3000TElm is missing resource definition.") - driver = BioShakeDriver(port=port) - super().__init__( - name=name, - driver=driver, - size_x=0, - size_y=0, - size_z=0, # TODO - child_location=Coordinate(0, 0, 0), # TODO - pedestal_size_z=0, # TODO - ) - self.tc = TemperatureControlCapability(backend=BioShakeTemperatureBackend(driver)) - self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) - self._capabilities = [self.tc, self.shaker] + raise NotImplementedError("BioShake3000TElm is missing resource definition.") -class BioShakeD30TElm(BioShake): +def BioShakeD30TElm(name: str, port: str) -> BioShake: """BioShake D30-T elm - shaking 200-2000 rpm, ELM, heating.""" + raise NotImplementedError("BioShakeD30TElm is missing resource definition.") - def __init__(self, name: str, port: str): - raise NotImplementedError("BioShakeD30TElm is missing resource definition.") - driver = BioShakeDriver(port=port) - super().__init__( - name=name, - driver=driver, - size_x=0, - size_y=0, - size_z=0, # TODO - child_location=Coordinate(0, 0, 0), # TODO - pedestal_size_z=0, # TODO - ) - self.tc = TemperatureControlCapability(backend=BioShakeTemperatureBackend(driver)) - self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) - self._capabilities = [self.tc, self.shaker] - - -# Shaking + heating + active cooling - - -class BioShakeQ1(BioShake): - """BioShake Q1 - shaking 200-3000 rpm, ELM, heating, active cooling.""" - - def __init__(self, name: str, port: str): - raise NotImplementedError("BioShakeQ1 is missing resource definition.") - driver = BioShakeDriver(port=port) - super().__init__( - name=name, - driver=driver, - size_x=0, - size_y=0, - size_z=0, # TODO - child_location=Coordinate(0, 0, 0), # TODO - pedestal_size_z=0, # TODO - ) - self.tc = TemperatureControlCapability( - backend=BioShakeTemperatureBackend(driver, supports_active_cooling=True) - ) - self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) - self._capabilities = [self.tc, self.shaker] +def BioShakeQ1(name: str, port: str) -> BioShake: + """BioShake Q1 - shaking 200-3000 rpm, ELM, heating, active cooling. -class BioShakeQ2(BioShake): + Dimensions defined with microplate adapter #2016-1024 (flat bottom standard). + """ + return BioShake( + name=name, + port=port, + size_x=142, # from spec + size_y=99, # from spec + size_z=97.30, # from spec + child_location=Coordinate(7.12, 6.76, 90.50), # from spec + pedestal_size_z=0, + has_shaking=True, + has_temperature=True, + supports_active_cooling=True, + model=BioShakeQ1.__name__, + ) + + +def BioShakeQ2(name: str, port: str) -> BioShake: """BioShake Q2 - shaking 200-3000 rpm, ELM, heating, active cooling.""" + raise NotImplementedError("BioShakeQ2 is missing resource definition.") - def __init__(self, name: str, port: str): - raise NotImplementedError("BioShakeQ2 is missing resource definition.") - driver = BioShakeDriver(port=port) - super().__init__( - name=name, - driver=driver, - size_x=0, - size_y=0, - size_z=0, # TODO - child_location=Coordinate(0, 0, 0), # TODO - pedestal_size_z=0, # TODO - ) - self.tc = TemperatureControlCapability( - backend=BioShakeTemperatureBackend(driver, supports_active_cooling=True) - ) - self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) - self._capabilities = [self.tc, self.shaker] - -# Temperature only - - -class Heatplate(BioShake): +def Heatplate(name: str, port: str) -> BioShake: """Heatplate - no shaking, heating only.""" + raise NotImplementedError("Heatplate is missing resource definition.") - def __init__(self, name: str, port: str): - raise NotImplementedError("Heatplate is missing resource definition.") - driver = BioShakeDriver(port=port) - super().__init__( - name=name, - driver=driver, - size_x=0, - size_y=0, - size_z=0, # TODO - child_location=Coordinate(0, 0, 0), # TODO - pedestal_size_z=0, # TODO - ) - self.tc = TemperatureControlCapability(backend=BioShakeTemperatureBackend(driver)) - self._capabilities = [self.tc] - -class ColdPlate(BioShake): +def ColdPlate(name: str, port: str) -> BioShake: """ColdPlate - no shaking, heating, active cooling.""" - - def __init__(self, name: str, port: str): - raise NotImplementedError("ColdPlate is missing resource definition.") - driver = BioShakeDriver(port=port) - super().__init__( - name=name, - driver=driver, - size_x=0, - size_y=0, - size_z=0, # TODO - child_location=Coordinate(0, 0, 0), # TODO - pedestal_size_z=0, # TODO - ) - self.tc = TemperatureControlCapability( - backend=BioShakeTemperatureBackend(driver, supports_active_cooling=True) - ) - self._capabilities = [self.tc] + raise NotImplementedError("ColdPlate is missing resource definition.") From 8afc75ddbf258d625c99cc3fbc024055de13dbd5 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 26 Mar 2026 22:21:49 -0700 Subject: [PATCH 15/69] Add liquid handling capabilities (#969) Co-authored-by: Claude Opus 4.6 (1M context) --- .../capabilities/liquid_handling/__init__.py | 18 + .../capabilities/liquid_handling/errors.py | 26 + .../capabilities/liquid_handling/head96.py | 568 ++++++++++ .../liquid_handling/head96_backend.py | 45 + .../capabilities/liquid_handling/pip.py | 817 +++++++++++++ .../liquid_handling/pip_backend.py | 76 ++ .../capabilities/liquid_handling/standard.py | 149 +++ .../capabilities/liquid_handling/utils.py | 77 ++ .../legacy/liquid_handling/liquid_handler.py | 1007 ++++++----------- 9 files changed, 2114 insertions(+), 669 deletions(-) create mode 100644 pylabrobot/capabilities/liquid_handling/__init__.py create mode 100644 pylabrobot/capabilities/liquid_handling/errors.py create mode 100644 pylabrobot/capabilities/liquid_handling/head96.py create mode 100644 pylabrobot/capabilities/liquid_handling/head96_backend.py create mode 100644 pylabrobot/capabilities/liquid_handling/pip.py create mode 100644 pylabrobot/capabilities/liquid_handling/pip_backend.py create mode 100644 pylabrobot/capabilities/liquid_handling/standard.py create mode 100644 pylabrobot/capabilities/liquid_handling/utils.py diff --git a/pylabrobot/capabilities/liquid_handling/__init__.py b/pylabrobot/capabilities/liquid_handling/__init__.py new file mode 100644 index 00000000000..fb6209587e7 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/__init__.py @@ -0,0 +1,18 @@ +from .errors import ChannelizedError, NoChannelError +from .head96 import Head96Capability +from .head96_backend import Head96Backend +from .pip import PIP +from .pip_backend import PIPBackend +from .standard import ( + Aspiration, + Dispense, + DropTipRack, + Mix, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + Pickup, + PickupTipRack, + TipDrop, +) diff --git a/pylabrobot/capabilities/liquid_handling/errors.py b/pylabrobot/capabilities/liquid_handling/errors.py new file mode 100644 index 00000000000..4a8283fd0d6 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/errors.py @@ -0,0 +1,26 @@ +"""Errors for liquid handling operations.""" + +from typing import Dict + + +class NoChannelError(Exception): + """Raised when no channel is available.""" + + +class BlowOutVolumeError(Exception): + """Raised when blow-out air volume is invalid.""" + + +class ChannelizedError(Exception): + """Raised by multi-channel operations. Contains per-channel errors.""" + + def __init__(self, errors: Dict[int, Exception], **kwargs): + self.errors = errors + self.kwargs = kwargs + + def __str__(self) -> str: + kwarg_string = ", ".join([f"{k}={v}" for k, v in self.kwargs.items()]) + return f"ChannelizedError(errors={self.errors}, {kwarg_string})" + + def __len__(self) -> int: + return len(self.errors) diff --git a/pylabrobot/capabilities/liquid_handling/head96.py b/pylabrobot/capabilities/liquid_handling/head96.py new file mode 100644 index 00000000000..f0c38de29e5 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/head96.py @@ -0,0 +1,568 @@ +"""Capability for 96-head liquid handling.""" + +import logging +from typing import Dict, List, Optional, Sequence, Union, cast + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready +from pylabrobot.resources import ( + Container, + Coordinate, + Plate, + Tip, + TipRack, + TipTracker, + Trash, + Well, + does_tip_tracking, + does_volume_tracking, +) + +from .head96_backend import Head96Backend +from .standard import ( + DropTipRack, + Mix, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + PickupTipRack, +) + +logger = logging.getLogger("pylabrobot") + + +class Head96Capability(Capability): + """96-head liquid handling: pick up tips, aspirate, dispense, drop tips. + + Faithfully ports the 96-head logic from the legacy LiquidHandler, including + tip tracking with commit/rollback, volume tracking, partial tip pickup, + single-container (trough) support, and convenience methods. + """ + + def __init__(self, backend: Head96Backend, default_offset: Coordinate = Coordinate.zero()): + super().__init__(backend=backend) + self.backend: Head96Backend = backend + self.head: Dict[int, TipTracker] = {} + self.default_offset: Coordinate = default_offset + + async def _on_setup(self): + await super()._on_setup() + self.head = {c: TipTracker(thing=f"96Head Channel {c}") for c in range(96)} + + def get_mounted_tips(self) -> List[Optional[Tip]]: + """Get the tips currently mounted on the 96-head. + + Returns: + A list of 96 tips, or None for channels without a tip. + """ + return [tracker.get_tip() if tracker.has_tip else None for tracker in self.head.values()] + + def update_head_state(self, state: Dict[int, Optional[Tip]]): + """Update the state of the 96-head. + + All keys must be valid channels (0-95). Channels not in `state` keep their current state. + """ + if not set(state.keys()).issubset(set(self.head.keys())): + raise ValueError("Invalid channel.") + for channel, tip in state.items(): + if tip is None: + if self.head[channel].has_tip: + self.head[channel].remove_tip() + else: + if self.head[channel].has_tip: + self.head[channel].remove_tip() + self.head[channel].add_tip(tip) + + def clear_head_state(self): + """Clear all tips from the 96-head.""" + self.update_head_state({c: None for c in self.head.keys()}) + + def serialize_state(self) -> Dict: + """Serialize the 96-head state for saving/restoring.""" + return {channel: tracker.serialize() for channel, tracker in self.head.items()} + + def load_state(self, state: Dict): + """Load 96-head state from a serialized dict.""" + for channel, tracker_state in state.items(): + self.head[channel].load_state(tracker_state) + + def _get_origin_tip_rack(self) -> Optional[TipRack]: + """Get the tip rack where the 96-head tips were picked up from. + + Returns None if no tips are mounted. Raises if tips are from different racks. + """ + tip_spot = self.head[0].get_tip_origin() + if tip_spot is None: + return None + tip_rack = tip_spot.parent + if tip_rack is None: + raise RuntimeError("No tip rack found for tip") + for i in range(tip_rack.num_items): + other_tip_spot = self.head[i].get_tip_origin() + if other_tip_spot is None: + raise RuntimeError("Not all channels have a tip origin") + if other_tip_spot.parent != tip_rack: + raise RuntimeError("All tips must be from the same tip rack") + return tip_rack + + @staticmethod + def _check_96_head_fits_in_container(container: Container) -> bool: + """Check if the 96 head can fit in the given container.""" + tip_width = 2 # approximation + distance_between_tips = 9 + return ( + container.get_absolute_size_x() >= tip_width + distance_between_tips * 11 + and container.get_absolute_size_y() >= tip_width + distance_between_tips * 7 + ) + + @need_capability_ready + async def pick_up_tips( + self, + tip_rack: TipRack, + offset: Coordinate = Coordinate.zero(), + backend_params: Optional[BackendParams] = None, + ): + """Pick up tips from a 96-tip rack. + + Not all tip spots need to have tips — only those with tips will be picked up. + + Examples: + >>> await head96.pick_up_tips(my_tiprack) + + Args: + tip_rack: The tip rack to pick up from. Must have 96 positions. + offset: Additional offset (added to default_offset). + backend_params: Vendor-specific parameters. + """ + + offset = self.default_offset + offset + + if not isinstance(tip_rack, TipRack): + raise TypeError(f"Resource must be a TipRack, got {tip_rack}") + if tip_rack.num_items != 96: + raise ValueError("Tip rack must have 96 tips") + + # queue operation on all tip trackers + tips: List[Optional[Tip]] = [] + for i, tip_spot in enumerate(tip_rack.get_all_items()): + if not does_tip_tracking() and self.head[i].has_tip: + self.head[i].remove_tip() + # only add tips where one is present + if tip_spot.has_tip(): + self.head[i].add_tip(tip_spot.get_tip(), origin=tip_spot, commit=False) + tips.append(tip_spot.get_tip()) + else: + tips.append(None) + if does_tip_tracking() and not tip_spot.tracker.is_disabled and tip_spot.has_tip(): + tip_spot.tracker.remove_tip() + + pickup_operation = PickupTipRack(resource=tip_rack, offset=offset, tips=tips) + try: + await self.backend.pick_up_tips96(pickup=pickup_operation, backend_params=backend_params) + except Exception as error: + for i, tip_spot in enumerate(tip_rack.get_all_items()): + if does_tip_tracking() and not tip_spot.tracker.is_disabled: + tip_spot.tracker.rollback() + self.head[i].rollback() + raise error + else: + for i, tip_spot in enumerate(tip_rack.get_all_items()): + if does_tip_tracking() and not tip_spot.tracker.is_disabled: + tip_spot.tracker.commit() + self.head[i].commit() + + @need_capability_ready + async def drop_tips( + self, + resource: Union[TipRack, Trash], + offset: Coordinate = Coordinate.zero(), + allow_nonzero_volume: bool = False, + backend_params: Optional[BackendParams] = None, + ): + """Drop tips using the 96-head. + + Examples: + >>> await head96.drop_tips(my_tiprack) + >>> await head96.drop_tips(trash) + + Args: + resource: The tip rack or trash to drop tips to. + offset: Additional offset (added to default_offset). + allow_nonzero_volume: If True, drop even if tips have liquid. + backend_params: Vendor-specific parameters. + """ + + offset = self.default_offset + offset + + if not isinstance(resource, (TipRack, Trash)): + raise TypeError(f"Resource must be a TipRack or Trash, got {resource}") + if isinstance(resource, TipRack) and resource.num_items != 96: + raise ValueError("Tip rack must have 96 tips") + + # queue operation on all tip trackers + for i in range(96): + if not self.head[i].has_tip: + continue + tip = self.head[i].get_tip() + if tip.tracker.get_used_volume() > 0 and not allow_nonzero_volume and does_volume_tracking(): + raise RuntimeError( + f"Cannot drop tip with volume {tip.tracker.get_used_volume()} on channel {i}" + ) + if isinstance(resource, TipRack): + tip_spot = resource.get_item(i) + if does_tip_tracking() and not tip_spot.tracker.is_disabled: + tip_spot.tracker.add_tip(tip, commit=False) + self.head[i].remove_tip() + + drop_operation = DropTipRack(resource=resource, offset=offset) + try: + await self.backend.drop_tips96(drop=drop_operation, backend_params=backend_params) + except Exception as e: + for i in range(96): + if isinstance(resource, TipRack): + tip_spot = resource.get_item(i) + if does_tip_tracking() and not tip_spot.tracker.is_disabled: + tip_spot.tracker.rollback() + self.head[i].rollback() + raise e + else: + for i in range(96): + if isinstance(resource, TipRack): + tip_spot = resource.get_item(i) + if does_tip_tracking() and not tip_spot.tracker.is_disabled: + tip_spot.tracker.commit() + self.head[i].commit() + + @need_capability_ready + async def return_tips( + self, + allow_nonzero_volume: bool = False, + offset: Coordinate = Coordinate.zero(), + drop_backend_params: Optional[BackendParams] = None, + ): + """Return the tips on the 96-head to the tip rack they were picked up from. + + Args: + allow_nonzero_volume: If True, return even if tips have liquid. + offset: Additional offset. + drop_backend_params: Vendor-specific parameters for the drop. + + Raises: + RuntimeError: If no tips have been picked up. + """ + tip_rack = self._get_origin_tip_rack() + if tip_rack is None: + raise RuntimeError("No tips have been picked up with the 96 head") + await self.drop_tips( + tip_rack, + allow_nonzero_volume=allow_nonzero_volume, + offset=offset, + backend_params=drop_backend_params, + ) + + @need_capability_ready + async def discard_tips( + self, + trash: Trash, + allow_nonzero_volume: bool = True, + drop_backend_params: Optional[BackendParams] = None, + ): + """Permanently discard tips from the 96-head into the trash. + + Args: + trash: The trash resource. + allow_nonzero_volume: If True, discard even if tips have liquid. + drop_backend_params: Vendor-specific parameters for the drop. + """ + await self.drop_tips( + trash, allow_nonzero_volume=allow_nonzero_volume, backend_params=drop_backend_params + ) + + @need_capability_ready + async def aspirate( + self, + resource: Union[Plate, Container, List[Well]], + volume: float, + offset: Coordinate = Coordinate.zero(), + flow_rate: Optional[float] = None, + liquid_height: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + mix: Optional[Mix] = None, + backend_params: Optional[BackendParams] = None, + ): + """Aspirate from all wells in a plate or from a container. + + Examples: + >>> await head96.aspirate(plate, volume=50) + >>> await head96.aspirate(trough, volume=50) + + Args: + resource: A Plate, Container, or list of 96 Wells. + volume: Volume to aspirate per channel. + offset: Additional offset (added to default_offset). + flow_rate: Flow rate in ul/s. None = machine default. + liquid_height: Liquid height in mm from bottom. None = machine default. + blow_out_air_volume: Air volume to aspirate after liquid (ul). + mix: Mix parameters. + backend_params: Vendor-specific parameters. + """ + + offset = self.default_offset + offset + + if not ( + isinstance(resource, (Plate, Container)) + or (isinstance(resource, list) and all(isinstance(w, Well) for w in resource)) + ): + raise TypeError(f"Resource must be a Plate, Container, or list of Wells, got {resource}") + + tips = [ch.get_tip() if ch.has_tip else None for ch in self.head.values()] + + volume = float(volume) + flow_rate = float(flow_rate) if flow_rate is not None else None + blow_out_air_volume = float(blow_out_air_volume) if blow_out_air_volume is not None else None + + # resolve resource to containers + containers: Sequence[Container] + if isinstance(resource, Plate): + if resource.has_lid(): + raise ValueError("Aspirating from plate with lid") + containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] + elif isinstance(resource, Container): + containers = [resource] + elif isinstance(resource, list): + containers = resource + else: + raise TypeError(f"Unexpected resource type: {type(resource)}") + + aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] + + if len(containers) == 1: # single container (trough) + container = containers[0] + if not self._check_96_head_fits_in_container(container): + raise ValueError("Container too small to accommodate 96 head") + + for tip in tips: + if tip is None: + continue + if not container.tracker.is_disabled and does_volume_tracking(): + container.tracker.remove_liquid(volume=volume) + tip.tracker.add_liquid(volume=volume) + + aspiration = MultiHeadAspirationContainer( + container=container, + volume=volume, + offset=offset, + flow_rate=flow_rate, + tips=tips, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=mix, + ) + else: # plate / list of wells + plate = containers[0].parent + for well in containers: + if well.parent != plate: + raise ValueError("All wells must be in the same plate") + if len(containers) != 96: + raise ValueError(f"aspirate96 expects 96 containers when a list, got {len(containers)}") + + for well, tip in zip(containers, tips): + if tip is None: + continue + if not well.tracker.is_disabled and does_volume_tracking(): + well.tracker.remove_liquid(volume=volume) + tip.tracker.add_liquid(volume=volume) + + aspiration = MultiHeadAspirationPlate( + wells=cast(List[Well], containers), + volume=volume, + offset=offset, + flow_rate=flow_rate, + tips=tips, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=mix, + ) + + try: + await self.backend.aspirate96(aspiration=aspiration, backend_params=backend_params) + except Exception: + for tip in tips: + if tip is not None: + tip.tracker.rollback() + for container in containers: + if does_volume_tracking() and not container.tracker.is_disabled: + container.tracker.rollback() + raise + else: + for tip in tips: + if tip is not None: + tip.tracker.commit() + for container in containers: + if does_volume_tracking() and not container.tracker.is_disabled: + container.tracker.commit() + + @need_capability_ready + async def dispense( + self, + resource: Union[Plate, Container, List[Well]], + volume: float, + offset: Coordinate = Coordinate.zero(), + flow_rate: Optional[float] = None, + liquid_height: Optional[float] = None, + blow_out_air_volume: Optional[float] = None, + mix: Optional[Mix] = None, + backend_params: Optional[BackendParams] = None, + ): + """Dispense to all wells in a plate or to a container. + + Examples: + >>> await head96.dispense(plate, volume=50) + + Args: + resource: A Plate, Container, or list of 96 Wells. + volume: Volume to dispense per channel. + offset: Additional offset (added to default_offset). + flow_rate: Flow rate in ul/s. None = machine default. + liquid_height: Liquid height in mm from bottom. None = machine default. + blow_out_air_volume: Air volume to dispense after liquid (ul). + mix: Mix parameters. + backend_params: Vendor-specific parameters. + """ + + offset = self.default_offset + offset + + if not ( + isinstance(resource, (Plate, Container)) + or (isinstance(resource, list) and all(isinstance(w, Well) for w in resource)) + ): + raise TypeError(f"Resource must be a Plate, Container, or list of Wells, got {resource}") + + tips = [ch.get_tip() if ch.has_tip else None for ch in self.head.values()] + + volume = float(volume) + flow_rate = float(flow_rate) if flow_rate is not None else None + blow_out_air_volume = float(blow_out_air_volume) if blow_out_air_volume is not None else None + + # resolve resource to containers + containers: Sequence[Container] + if isinstance(resource, Plate): + if resource.has_lid(): + raise ValueError("Dispensing to plate with lid is not possible. Remove the lid first.") + containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] + elif isinstance(resource, Container): + containers = [resource] + elif isinstance(resource, list): + containers = resource + else: + raise TypeError(f"Unexpected resource type: {type(resource)}") + + # remove liquid from tips + for tip in tips: + if tip is None: + continue + if does_volume_tracking(): + tip.tracker.remove_liquid(volume=volume) + elif tip.tracker.get_used_volume() < volume: + tip.tracker.remove_liquid(volume=min(tip.tracker.get_used_volume(), volume)) + + dispense_op: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer] + + if len(containers) == 1: # single container (trough) + container = containers[0] + if not self._check_96_head_fits_in_container(container): + raise ValueError("Container too small to accommodate 96 head") + + if not container.tracker.is_disabled and does_volume_tracking(): + container.tracker.add_liquid(volume=len([t for t in tips if t is not None]) * volume) + + dispense_op = MultiHeadDispenseContainer( + container=container, + volume=volume, + offset=offset, + flow_rate=flow_rate, + tips=tips, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=mix, + ) + else: # plate / list of wells + plate = containers[0].parent + for well in containers: + if well.parent != plate: + raise ValueError("All wells must be in the same plate") + if len(containers) != 96: + raise ValueError(f"dispense96 expects 96 wells, got {len(containers)}") + + for well, tip in zip(containers, tips): + if tip is None: + continue + if not well.tracker.is_disabled and does_volume_tracking(): + well.tracker.add_liquid(volume=volume) + + dispense_op = MultiHeadDispensePlate( + wells=cast(List[Well], containers), + volume=volume, + offset=offset, + flow_rate=flow_rate, + tips=tips, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=mix, + ) + + try: + await self.backend.dispense96(dispense=dispense_op, backend_params=backend_params) + except Exception: + for tip in tips: + if tip is not None: + tip.tracker.rollback() + for container in containers: + if does_volume_tracking() and not container.tracker.is_disabled: + container.tracker.rollback() + raise + else: + for tip in tips: + if tip is not None: + tip.tracker.commit() + for container in containers: + if does_volume_tracking() and not container.tracker.is_disabled: + container.tracker.commit() + + @need_capability_ready + async def stamp( + self, + source: Plate, + target: Plate, + volume: float, + aspiration_flow_rate: Optional[float] = None, + dispense_flow_rate: Optional[float] = None, + aspirate_backend_params: Optional[BackendParams] = None, + dispense_backend_params: Optional[BackendParams] = None, + ): + """Stamp (aspirate and dispense) one plate onto another. + + Args: + source: The source plate. + target: The target plate. + volume: The volume to transfer. + aspiration_flow_rate: Flow rate for aspiration (ul/s). + dispense_flow_rate: Flow rate for dispense (ul/s). + aspirate_backend_params: Vendor-specific parameters for aspiration. + dispense_backend_params: Vendor-specific parameters for dispense. + """ + if (source.num_items_x, source.num_items_y) != (target.num_items_x, target.num_items_y): + raise ValueError("Source and target plates must be the same shape") + + await self.aspirate( + resource=source, + volume=volume, + flow_rate=aspiration_flow_rate, + backend_params=aspirate_backend_params, + ) + await self.dispense( + resource=target, + volume=volume, + flow_rate=dispense_flow_rate, + backend_params=dispense_backend_params, + ) diff --git a/pylabrobot/capabilities/liquid_handling/head96_backend.py b/pylabrobot/capabilities/liquid_handling/head96_backend.py new file mode 100644 index 00000000000..19a6d3299b0 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/head96_backend.py @@ -0,0 +1,45 @@ +"""Abstract backend for 96-head liquid handling.""" + +from abc import ABCMeta, abstractmethod +from typing import Optional, Union + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend + +from .standard import ( + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + PickupTipRack, +) + + +class Head96Backend(CapabilityBackend, metaclass=ABCMeta): + """Backend for 96-head liquid handling operations.""" + + @abstractmethod + async def pick_up_tips96( + self, pickup: PickupTipRack, backend_params: Optional[BackendParams] = None + ): + """Pick up tips from a tip rack using the 96-head.""" + + @abstractmethod + async def drop_tips96(self, drop: DropTipRack, backend_params: Optional[BackendParams] = None): + """Drop tips using the 96-head.""" + + @abstractmethod + async def aspirate96( + self, + aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer], + backend_params: Optional[BackendParams] = None, + ): + """Aspirate using the 96-head.""" + + @abstractmethod + async def dispense96( + self, + dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer], + backend_params: Optional[BackendParams] = None, + ): + """Dispense using the 96-head.""" diff --git a/pylabrobot/capabilities/liquid_handling/pip.py b/pylabrobot/capabilities/liquid_handling/pip.py new file mode 100644 index 00000000000..2d5b89cf943 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/pip.py @@ -0,0 +1,817 @@ +"""Capability for independent-channel liquid handling.""" + +import contextlib +import logging +from typing import Dict, Generator, List, Literal, Optional, Sequence, Union + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready +from pylabrobot.resources import ( + Container, + Coordinate, + Plate, + Tip, + TipSpot, + TipTracker, + Trash, + Well, + does_tip_tracking, + does_volume_tracking, +) +from pylabrobot.resources.errors import HasTipError + +from .errors import BlowOutVolumeError, ChannelizedError +from .pip_backend import PIPBackend +from .standard import Aspiration, Dispense, Mix, Pickup, TipDrop +from .utils import ( + get_tight_single_resource_liquid_op_offsets, + get_wide_single_resource_liquid_op_offsets, +) + +logger = logging.getLogger("pylabrobot") + + +class PIP(Capability): + """Independent-channel liquid handling: pick up tips, aspirate, dispense, drop tips. + + Faithfully ports the tip tracking, volume tracking, validation, spread modes, and + error handling from the legacy LiquidHandler frontend. + """ + + def __init__(self, backend: PIPBackend): + super().__init__(backend=backend) + self.backend: PIPBackend = backend + self.head: Dict[int, TipTracker] = {} + self._default_use_channels: Optional[List[int]] = None + self._blow_out_air_volume: Optional[List[Optional[float]]] = None + + async def _on_setup(self): + await super()._on_setup() + self.head = {c: TipTracker(thing=f"Channel {c}") for c in range(self.backend.num_channels)} + + @property + def num_channels(self) -> int: + return self.backend.num_channels + + def get_mounted_tips(self) -> List[Optional[Tip]]: + """Get the tips currently mounted on the head. + + Returns: + A list of tips, or None for channels without a tip. + """ + return [tracker.get_tip() if tracker.has_tip else None for tracker in self.head.values()] + + def update_head_state(self, state: Dict[int, Optional[Tip]]): + """Update the state of the head. + + All keys in `state` must be valid channels. Channels not in `state` keep their current state. + + Args: + state: A dictionary mapping channels to tips. None means no tip. + """ + if not set(state.keys()).issubset(set(self.head.keys())): + raise ValueError("Invalid channel.") + for channel, tip in state.items(): + if tip is None: + if self.head[channel].has_tip: + self.head[channel].remove_tip() + else: + if self.head[channel].has_tip: + self.head[channel].remove_tip() + self.head[channel].add_tip(tip) + + def clear_head_state(self): + """Clear all tips from the head.""" + self.update_head_state({c: None for c in self.head.keys()}) + + def serialize_state(self) -> Dict: + """Serialize the head state for saving/restoring.""" + return {channel: tracker.serialize() for channel, tracker in self.head.items()} + + def load_state(self, state: Dict): + """Load head state from a serialized dict.""" + for channel, tracker_state in state.items(): + self.head[channel].load_state(tracker_state) + + def _make_sure_channels_exist(self, channels: List[int]): + invalid = [c for c in channels if c not in self.head] + if invalid: + raise ValueError(f"Invalid channels: {invalid}") + + @need_capability_ready + async def pick_up_tips( + self, + tip_spots: List[TipSpot], + use_channels: Optional[List[int]] = None, + offsets: Optional[List[Coordinate]] = None, + backend_params: Optional[BackendParams] = None, + ): + """Pick up tips from tip spots. + + Examples: + Pick up all tips in the first column: + + >>> await lh.pick_up_tips(tips_resource["A1":"H1"]) + + Pick up tips on odd rows, skipping the other channels: + + >>> await lh.pick_up_tips(tips_resource["A1", "C1", "E1", "G1"], use_channels=[0, 2, 4, 6]) + + Args: + tip_spots: List of tip spots to pick up tips from. + use_channels: List of channels to use. If None, the first len(tip_spots) channels are used. + offsets: List of offsets for each pickup. Defaults to zero. + backend_params: Vendor-specific parameters. + + Raises: + HasTipError: If a channel already has a tip. + NoTipError: If a spot does not have a tip. + """ + + not_tip_spots = [ts for ts in tip_spots if not isinstance(ts, TipSpot)] + if not_tip_spots: + raise TypeError(f"Resources must be TipSpots, got {not_tip_spots}") + + use_channels = use_channels or self._default_use_channels or list(range(len(tip_spots))) + if len(set(use_channels)) != len(use_channels): + raise ValueError("Channels must be unique.") + + tips = [tip_spot.get_tip() for tip_spot in tip_spots] + offsets = offsets or [Coordinate.zero()] * len(tip_spots) + + # check tip compatibility + if not all( + self.backend.can_pick_up_tip(channel, tip) for channel, tip in zip(use_channels, tips) + ): + cannot = [ + ch for ch, tip in zip(use_channels, tips) if not self.backend.can_pick_up_tip(ch, tip) + ] + raise RuntimeError(f"Cannot pick up tips on channels {cannot}.") + + self._make_sure_channels_exist(use_channels) + if not (len(tip_spots) == len(offsets) == len(use_channels)): + raise ValueError("Number of tips, offsets, and use_channels must be equal.") + + pickups = [Pickup(resource=ts, offset=o, tip=t) for ts, o, t in zip(tip_spots, offsets, tips)] + + # queue operations on trackers + for channel, op in zip(use_channels, pickups): + if self.head[channel].has_tip: + raise HasTipError("Channel has tip") + if does_tip_tracking() and not op.resource.tracker.is_disabled: + op.resource.tracker.remove_tip() + self.head[channel].add_tip(op.tip, origin=op.resource, commit=False) + + # execute + error: Optional[BaseException] = None + try: + await self.backend.pick_up_tips( + ops=pickups, use_channels=use_channels, backend_params=backend_params + ) + except BaseException as e: + error = e + + # determine per-channel success + successes = [error is None] * len(pickups) + if error is not None: + try: + tip_presence = await self.backend.request_tip_presence() + successes = [tip_presence[ch] is True for ch in use_channels] + except Exception as tip_presence_error: + if not isinstance(tip_presence_error, NotImplementedError): + logger.warning("Failed to query tip presence after error: %s", tip_presence_error) + if isinstance(error, ChannelizedError): + successes = [ch not in error.errors for ch in use_channels] + + # commit or rollback + for channel, op, success in zip(use_channels, pickups, successes): + if does_tip_tracking() and not op.resource.tracker.is_disabled: + (op.resource.tracker.commit if success else op.resource.tracker.rollback)() + (self.head[channel].commit if success else self.head[channel].rollback)() + + if error is not None: + raise error + + @need_capability_ready + async def drop_tips( + self, + tip_spots: Sequence[Union[TipSpot, Trash]], + use_channels: Optional[List[int]] = None, + offsets: Optional[List[Coordinate]] = None, + allow_nonzero_volume: bool = False, + backend_params: Optional[BackendParams] = None, + ): + """Drop tips to tip spots or trash. + + Args: + tip_spots: Tip spots or trash to drop to. + use_channels: List of channels to use. If None, the first len(tip_spots) channels are used. + offsets: List of offsets for each drop. Defaults to zero. + allow_nonzero_volume: If True, drop even if the tip has liquid. Otherwise raise. + backend_params: Vendor-specific parameters. + + Raises: + NoTipError: If a channel does not have a tip. + HasTipError: If a spot already has a tip. + """ + + not_valid = [ts for ts in tip_spots if not isinstance(ts, (TipSpot, Trash))] + if not_valid: + raise TypeError(f"Resources must be TipSpots or Trash, got {not_valid}") + + use_channels = use_channels or self._default_use_channels or list(range(len(tip_spots))) + if len(set(use_channels)) != len(use_channels): + raise ValueError("Channels must be unique.") + + tips = [] + for channel in use_channels: + tip = self.head[channel].get_tip() + if tip.tracker.get_used_volume() > 0 and not allow_nonzero_volume: + raise RuntimeError(f"Cannot drop tip with volume {tip.tracker.get_used_volume()}") + tips.append(tip) + + offsets = offsets or [Coordinate.zero()] * len(tip_spots) + + self._make_sure_channels_exist(use_channels) + if not (len(tip_spots) == len(offsets) == len(use_channels) == len(tips)): + raise ValueError("Number of tip_spots, offsets, use_channels, and tips must be equal.") + + drops = [TipDrop(resource=ts, offset=o, tip=t) for ts, t, o in zip(tip_spots, tips, offsets)] + + # queue operations on trackers + for channel, op in zip(use_channels, drops): + if ( + does_tip_tracking() + and isinstance(op.resource, TipSpot) + and not op.resource.tracker.is_disabled + ): + op.resource.tracker.add_tip(op.tip, commit=False) + self.head[channel].remove_tip() + + # execute + error: Optional[BaseException] = None + try: + await self.backend.drop_tips( + ops=drops, use_channels=use_channels, backend_params=backend_params + ) + except BaseException as e: + error = e + + # determine per-channel success + successes = [error is None] * len(drops) + if error is not None: + try: + tip_presence = await self.backend.request_tip_presence() + successes = [tip_presence[ch] is False for ch in use_channels] + except Exception as tip_presence_error: + if not isinstance(tip_presence_error, NotImplementedError): + logger.warning("Failed to query tip presence after error: %s", tip_presence_error) + if isinstance(error, ChannelizedError): + successes = [ch not in error.errors for ch in use_channels] + + # commit or rollback + for channel, op, success in zip(use_channels, drops, successes): + if ( + does_tip_tracking() + and isinstance(op.resource, TipSpot) + and not op.resource.tracker.is_disabled + ): + (op.resource.tracker.commit if success else op.resource.tracker.rollback)() + (self.head[channel].commit if success else self.head[channel].rollback)() + + if error is not None: + raise error + + @need_capability_ready + async def return_tips( + self, + use_channels: Optional[List[int]] = None, + allow_nonzero_volume: bool = False, + offsets: Optional[List[Coordinate]] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + """Return all tips currently picked up to their original place. + + Args: + use_channels: Channels to return. If None, all channels with tips are used. + allow_nonzero_volume: If True, return even if the tip has liquid. + offsets: List of offsets for each drop. + drop_backend_params: Vendor-specific parameters for the drop. + + Raises: + RuntimeError: If no tips have been picked up. + """ + + tip_spots: List[TipSpot] = [] + channels: List[int] = [] + + for channel, tracker in self.head.items(): + if use_channels is not None and channel not in use_channels: + continue + if tracker.has_tip: + origin = tracker.get_tip_origin() + if origin is None: + raise RuntimeError("No tip origin found.") + tip_spots.append(origin) + channels.append(channel) + + if len(tip_spots) == 0: + raise RuntimeError("No tips have been picked up.") + + await self.drop_tips( + tip_spots=tip_spots, + use_channels=channels, + allow_nonzero_volume=allow_nonzero_volume, + offsets=offsets, + backend_params=drop_backend_params, + ) + + @need_capability_ready + async def discard_tips( + self, + trash: Trash, + use_channels: Optional[List[int]] = None, + allow_nonzero_volume: bool = True, + offsets: Optional[List[Coordinate]] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + """Permanently discard tips in the trash. + + Args: + trash: The trash resource. + use_channels: Channels to discard. If None, all channels with tips are used. + allow_nonzero_volume: If True, discard even if the tip has liquid. + offsets: List of offsets for each drop. + drop_backend_params: Vendor-specific parameters for the drop. + """ + + if use_channels is None: + use_channels = [c for c, t in self.head.items() if t.has_tip] + + n = len(use_channels) + if n == 0: + raise RuntimeError("No tips have been picked up and no channels were specified.") + + trash_offsets = get_tight_single_resource_liquid_op_offsets(trash, num_channels=n) + offsets = [ + o + to if o is not None else to + for o, to in zip(offsets or [None] * n, trash_offsets) # type: ignore + ] + + await self.drop_tips( + tip_spots=[trash] * n, + use_channels=use_channels, + offsets=offsets, + allow_nonzero_volume=allow_nonzero_volume, + backend_params=drop_backend_params, + ) + + @need_capability_ready + async def move_tips( + self, + source_tip_spots: List[TipSpot], + dest_tip_spots: List[TipSpot], + pick_up_backend_params: Optional[BackendParams] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + """Move tips from one tip rack to another. + + Examples: + >>> await cap.move_tips(source_rack["A1":"A8"], dest_rack["B1":"B8"]) + """ + if len(source_tip_spots) != len(dest_tip_spots): + raise ValueError("Number of source and destination tip spots must match.") + + use_channels = list(range(len(source_tip_spots))) + await self.pick_up_tips( + tip_spots=source_tip_spots, + use_channels=use_channels, + backend_params=pick_up_backend_params, + ) + await self.drop_tips( + tip_spots=dest_tip_spots, + use_channels=use_channels, + backend_params=drop_backend_params, + ) + + @need_capability_ready + async def aspirate( + self, + resources: Sequence[Container], + vols: List[float], + use_channels: Optional[List[int]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + spread: Literal["wide", "tight", "custom"] = "wide", + mix: Optional[List[Mix]] = None, + backend_params: Optional[BackendParams] = None, + ): + """Aspirate liquid from the specified containers. + + Examples: + Aspirate 50 uL from the first column: + + >>> await cap.aspirate(plate["A1:H1"], vols=[50]*8) + + Aspirate from a single container with multiple channels spread evenly: + + >>> await cap.aspirate([trough], vols=[50]*4, use_channels=[0,1,2,3]) + + Args: + resources: Containers to aspirate from. If a single resource is given with multiple channels, + channels are spread across it according to `spread`. + vols: Volume to aspirate per channel. + use_channels: Channels to use. Defaults to 0..len(resources)-1. + flow_rates: Flow rate per channel (ul/s). None = machine default. + offsets: Offset per channel. + liquid_height: Liquid height per channel (mm from bottom). None = machine default. + blow_out_air_volume: Air volume to aspirate after liquid (ul). None = machine default. + spread: How to space channels on a single resource: "wide", "tight", or "custom". + mix: Mix parameters per channel. + backend_params: Vendor-specific parameters. + """ + + not_containers = [r for r in resources if not isinstance(r, Container)] + if not_containers: + raise TypeError(f"Resources must be Containers, got {not_containers}") + + use_channels = use_channels or self._default_use_channels or list(range(len(resources))) + if len(set(use_channels)) != len(use_channels): + raise ValueError("Channels must be unique.") + + offsets = offsets or [Coordinate.zero()] * len(use_channels) + flow_rates = flow_rates or [None] * len(use_channels) + liquid_height = liquid_height or [None] * len(use_channels) + blow_out_air_volume = blow_out_air_volume or [None] * len(use_channels) + + vols = [float(v) for v in vols] + flow_rates = [float(fr) if fr is not None else None for fr in flow_rates] + liquid_height = [float(lh) if lh is not None else None for lh in liquid_height] + blow_out_air_volume = [float(bav) if bav is not None else None for bav in blow_out_air_volume] + + self._blow_out_air_volume = blow_out_air_volume + tips = [self.head[channel].get_tip() for channel in use_channels] + + for resource in resources: + if isinstance(resource.parent, Plate) and resource.parent.has_lid(): + raise ValueError("Aspirating from a well with a lid is not supported.") + + self._make_sure_channels_exist(use_channels) + for name, param in [ + ("resources", resources), + ("vols", vols), + ("offsets", offsets), + ("flow_rates", flow_rates), + ("liquid_height", liquid_height), + ("blow_out_air_volume", blow_out_air_volume), + ]: + if len(param) != len(use_channels): + raise ValueError( + f"Length of {name} must match use_channels: {len(param)} != {len(use_channels)}" + ) + + # spread channels across a single resource + if len(set(resources)) == 1: + resource = resources[0] + resources = [resource] * len(use_channels) + if spread == "tight": + center_offsets = get_tight_single_resource_liquid_op_offsets( + resource=resource, num_channels=len(use_channels) + ) + elif spread == "wide": + center_offsets = get_wide_single_resource_liquid_op_offsets( + resource=resource, num_channels=len(use_channels) + ) + elif spread == "custom": + center_offsets = [Coordinate.zero()] * len(use_channels) + else: + raise ValueError("Invalid spread. Must be 'tight', 'wide', or 'custom'.") + offsets = [c + o for c, o in zip(center_offsets, offsets)] + + aspirations = [ + Aspiration( + resource=r, + volume=v, + offset=o, + flow_rate=fr, + liquid_height=lh, + tip=t, + blow_out_air_volume=bav, + mix=m, + ) + for r, v, o, fr, lh, t, bav, m in zip( + resources, + vols, + offsets, + flow_rates, + liquid_height, + tips, + blow_out_air_volume, + mix or [None] * len(use_channels), # type: ignore + ) + ] + + # queue volume tracking + for op in aspirations: + if does_volume_tracking(): + if not op.resource.tracker.is_disabled: + op.resource.tracker.remove_liquid(op.volume) + op.tip.tracker.add_liquid(volume=op.volume) + + # execute + error: Optional[Exception] = None + try: + await self.backend.aspirate( + ops=aspirations, use_channels=use_channels, backend_params=backend_params + ) + except Exception as e: + error = e + + # determine per-channel success + successes = [error is None] * len(aspirations) + if error is not None and isinstance(error, ChannelizedError): + successes = [ch not in error.errors for ch in use_channels] + + # commit or rollback + for channel, op, success in zip(use_channels, aspirations, successes): + if does_volume_tracking(): + if not op.resource.tracker.is_disabled: + (op.resource.tracker.commit if success else op.resource.tracker.rollback)() + tip_volume_tracker = self.head[channel].get_tip().tracker + (tip_volume_tracker.commit if success else tip_volume_tracker.rollback)() + + if error is not None: + raise error + + @need_capability_ready + async def dispense( + self, + resources: Sequence[Container], + vols: List[float], + use_channels: Optional[List[int]] = None, + flow_rates: Optional[List[Optional[float]]] = None, + offsets: Optional[List[Coordinate]] = None, + liquid_height: Optional[List[Optional[float]]] = None, + blow_out_air_volume: Optional[List[Optional[float]]] = None, + spread: Literal["wide", "tight", "custom"] = "wide", + mix: Optional[List[Mix]] = None, + backend_params: Optional[BackendParams] = None, + ): + """Dispense liquid to the specified containers. + + Examples: + Dispense 50 uL to the first column: + + >>> await cap.dispense(plate["A1:H1"], vols=[50]*8) + + Args: + resources: Containers to dispense to. + vols: Volume to dispense per channel. + use_channels: Channels to use. Defaults to 0..len(resources)-1. + flow_rates: Flow rate per channel (ul/s). None = machine default. + offsets: Offset per channel. + liquid_height: Liquid height per channel (mm from bottom). None = machine default. + blow_out_air_volume: Air volume to dispense after liquid (ul). None = machine default. + spread: How to space channels on a single resource: "wide", "tight", or "custom". + mix: Mix parameters per channel. + backend_params: Vendor-specific parameters. + """ + + not_containers = [r for r in resources if not isinstance(r, Container)] + if not_containers: + raise TypeError(f"Resources must be Containers, got {not_containers}") + + use_channels = use_channels or self._default_use_channels or list(range(len(resources))) + if len(set(use_channels)) != len(use_channels): + raise ValueError("Channels must be unique.") + + offsets = offsets or [Coordinate.zero()] * len(use_channels) + flow_rates = flow_rates or [None] * len(use_channels) + liquid_height = liquid_height or [None] * len(use_channels) + blow_out_air_volume = blow_out_air_volume or [None] * len(use_channels) + + vols = [float(v) for v in vols] + flow_rates = [float(fr) if fr is not None else None for fr in flow_rates] + liquid_height = [float(lh) if lh is not None else None for lh in liquid_height] + blow_out_air_volume = [float(bav) if bav is not None else None for bav in blow_out_air_volume] + + # spread channels across a single resource + if len(set(resources)) == 1: + resource = resources[0] + resources = [resource] * len(use_channels) + if spread == "tight": + center_offsets = get_tight_single_resource_liquid_op_offsets( + resource=resource, num_channels=len(use_channels) + ) + elif spread == "wide": + center_offsets = get_wide_single_resource_liquid_op_offsets( + resource=resource, num_channels=len(use_channels) + ) + elif spread == "custom": + center_offsets = [Coordinate.zero()] * len(use_channels) + else: + raise ValueError("Invalid spread. Must be 'tight', 'wide', or 'custom'.") + offsets = [c + o for c, o in zip(center_offsets, offsets)] + + tips = [self.head[channel].get_tip() for channel in use_channels] + + # check blow-out air volume against what was aspirated + if does_volume_tracking(): + if any(bav is not None and bav != 0.0 for bav in blow_out_air_volume): + if self._blow_out_air_volume is None: + raise BlowOutVolumeError("No blowout volume was aspirated.") + for requested_bav, done_bav in zip(blow_out_air_volume, self._blow_out_air_volume): + if requested_bav is not None and done_bav is not None and requested_bav > done_bav: + raise BlowOutVolumeError("Blowout volume is larger than aspirated volume") + + for resource in resources: + if isinstance(resource.parent, Plate) and resource.parent.has_lid(): + raise ValueError("Dispensing to a well with a lid is not supported.") + + for name, param in [ + ("resources", resources), + ("vols", vols), + ("offsets", offsets), + ("flow_rates", flow_rates), + ("liquid_height", liquid_height), + ("blow_out_air_volume", blow_out_air_volume), + ]: + if len(param) != len(use_channels): + raise ValueError( + f"Length of {name} must match use_channels: {len(param)} != {len(use_channels)}" + ) + + dispenses = [ + Dispense( + resource=r, + volume=v, + offset=o, + flow_rate=fr, + liquid_height=lh, + tip=t, + blow_out_air_volume=bav, + mix=m, + ) + for r, v, o, fr, lh, t, bav, m in zip( + resources, + vols, + offsets, + flow_rates, + liquid_height, + tips, + blow_out_air_volume, + mix or [None] * len(use_channels), # type: ignore + ) + ] + + # queue volume tracking + for op in dispenses: + if does_volume_tracking(): + if not op.resource.tracker.is_disabled: + op.resource.tracker.add_liquid(volume=op.volume) + op.tip.tracker.remove_liquid(op.volume) + + # execute + error: Optional[Exception] = None + try: + await self.backend.dispense( + ops=dispenses, use_channels=use_channels, backend_params=backend_params + ) + except Exception as e: + error = e + + # determine per-channel success + successes = [error is None] * len(dispenses) + if error is not None and isinstance(error, ChannelizedError): + successes = [ch not in error.errors for ch in use_channels] + + # commit or rollback + for channel, op, success in zip(use_channels, dispenses, successes): + if does_volume_tracking(): + if not op.resource.tracker.is_disabled: + (op.resource.tracker.commit if success else op.resource.tracker.rollback)() + tip_volume_tracker = self.head[channel].get_tip().tracker + (tip_volume_tracker.commit if success else tip_volume_tracker.rollback)() + + if any(bav is not None for bav in blow_out_air_volume): + self._blow_out_air_volume = None + + if error is not None: + raise error + + @need_capability_ready + async def transfer( + self, + source: Well, + targets: List[Well], + source_vol: Optional[float] = None, + ratios: Optional[List[float]] = None, + target_vols: Optional[List[float]] = None, + aspiration_flow_rate: Optional[float] = None, + dispense_flow_rates: Optional[List[Optional[float]]] = None, + aspirate_backend_params: Optional[BackendParams] = None, + dispense_backend_params: Optional[BackendParams] = None, + ): + """Transfer liquid from one well to multiple targets. + + Examples: + Transfer 50 uL from A1 to B1: + + >>> await cap.transfer(plate["A1"], plate["B1"], source_vol=50) + + Transfer 80 uL equally to the first column: + + >>> await cap.transfer(plate["A1"], plate["A1:H1"], source_vol=80) + + Transfer 60 uL in a 2:1 ratio: + + >>> await cap.transfer(plate["A1"], plate["B1:C1"], source_vol=60, ratios=[2, 1]) + + Args: + source: The source well. + targets: The target wells. + source_vol: The total volume to aspirate from source. + ratios: Ratios for distributing liquid. If None, distribute equally. + target_vols: Explicit volumes per target. Mutually exclusive with source_vol/ratios. + aspiration_flow_rate: Flow rate for aspiration (ul/s). + dispense_flow_rates: Flow rates for dispense per target (ul/s). + aspirate_backend_params: Vendor-specific parameters for aspiration. + dispense_backend_params: Vendor-specific parameters for dispense. + """ + + if target_vols is not None: + if ratios is not None: + raise TypeError("Cannot specify ratios and target_vols at the same time") + if source_vol is not None: + raise TypeError("Cannot specify source_vol and target_vols at the same time") + else: + if source_vol is None: + raise TypeError("Must specify either source_vol or target_vols") + if ratios is None: + ratios = [1] * len(targets) + target_vols = [source_vol * r / sum(ratios) for r in ratios] + + await self.aspirate( + resources=[source], + vols=[sum(target_vols)], + flow_rates=[aspiration_flow_rate], + backend_params=aspirate_backend_params, + ) + dispense_flow_rates = dispense_flow_rates or [None] * len(targets) + for target, vol, dfr in zip(targets, target_vols, dispense_flow_rates): + await self.dispense( + resources=[target], + vols=[vol], + flow_rates=[dfr], + use_channels=[0], + backend_params=dispense_backend_params, + ) + + @contextlib.contextmanager + def use_channels(self, channels: List[int]) -> Generator[None, None, None]: + """Temporarily use the specified channels as default for all operations. + + Examples: + >>> with cap.use_channels([2]): + ... await cap.pick_up_tips(tip_rack["A1"]) + ... await cap.aspirate(plate["A1"], vols=[50]) + """ + self._default_use_channels = channels + try: + yield + finally: + self._default_use_channels = None + + @contextlib.asynccontextmanager + async def use_tips( + self, + tip_spots: List[TipSpot], + trash: Trash, + channels: Optional[List[int]] = None, + discard: bool = True, + pick_up_backend_params: Optional[BackendParams] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + """Context manager that picks up tips on entry and discards/returns on exit. + + Examples: + >>> async with cap.use_tips(tip_rack["A1":"H1"], trash=trash): + ... await cap.aspirate(plate["A1":"H1"], vols=[50]*8) + ... await cap.dispense(plate["A1":"H1"], vols=[50]*8) + """ + if channels is None: + channels = list(range(len(tip_spots))) + if len(tip_spots) != len(channels): + raise ValueError("Number of tip spots and channels must match.") + + await self.pick_up_tips(tip_spots, use_channels=channels, backend_params=pick_up_backend_params) + try: + yield + finally: + if discard: + await self.discard_tips( + trash=trash, use_channels=channels, drop_backend_params=drop_backend_params + ) + else: + await self.return_tips(use_channels=channels, drop_backend_params=drop_backend_params) diff --git a/pylabrobot/capabilities/liquid_handling/pip_backend.py b/pylabrobot/capabilities/liquid_handling/pip_backend.py new file mode 100644 index 00000000000..373db5371ba --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/pip_backend.py @@ -0,0 +1,76 @@ +"""Abstract backend for independent-channel liquid handling.""" + +from abc import ABCMeta, abstractmethod +from typing import List, Optional + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend +from pylabrobot.resources import Tip + +from .standard import Aspiration, Dispense, Pickup, TipDrop + + +class PIPBackend(CapabilityBackend, metaclass=ABCMeta): + """Backend for independent-channel liquid handling operations. + + Each operation takes a list of ops (one per channel being used) and a list + of channel indices specifying which physical channels to use. + """ + + @property + @abstractmethod + def num_channels(self) -> int: + """The number of independent channels available.""" + + @abstractmethod + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Pick up tips from the specified tip spots.""" + + @abstractmethod + async def drop_tips( + self, + ops: List[TipDrop], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Drop tips to the specified resources.""" + + @abstractmethod + async def aspirate( + self, + ops: List[Aspiration], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Aspirate liquid from the specified containers.""" + + @abstractmethod + async def dispense( + self, + ops: List[Dispense], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + """Dispense liquid to the specified containers.""" + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + """Check if the tip can be picked up by the specified channel. + + Does not consider if a tip is already mounted — just whether the tip is compatible. + Default returns True; override for hardware-specific constraints. + """ + return True + + async def request_tip_presence(self) -> List[Optional[bool]]: + """Request the tip presence status for each channel. + + Returns a list of length `num_channels` where each element is True if a tip is mounted, + False if not, or None if unknown. + + Default raises NotImplementedError; override if hardware supports tip presence detection. + """ + raise NotImplementedError() diff --git a/pylabrobot/capabilities/liquid_handling/standard.py b/pylabrobot/capabilities/liquid_handling/standard.py new file mode 100644 index 00000000000..af006699664 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/standard.py @@ -0,0 +1,149 @@ +"""Standard types for liquid handling operations.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Sequence, Union + +from pylabrobot.resources import Coordinate + +if TYPE_CHECKING: + from pylabrobot.resources import Container, Tip, TipRack, TipSpot, Trash, Well + + +@dataclass(frozen=True) +class Mix: + """Mix parameters for aspiration/dispense operations.""" + + volume: float + repetitions: int + flow_rate: float + + +# --------------------------------------------------------------------------- +# Independent channel operations +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Pickup: + """Pick up a tip from a tip spot.""" + + resource: TipSpot + offset: Coordinate + tip: Tip + + +@dataclass(frozen=True) +class TipDrop: + """Drop a tip to a tip spot or trash.""" + + resource: Union[TipSpot, Trash] + offset: Coordinate + tip: Tip + + +@dataclass(frozen=True) +class Aspiration: + """Aspirate liquid from a container using an independent channel.""" + + resource: Container + offset: Coordinate + tip: Tip + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class Dispense: + """Dispense liquid to a container using an independent channel.""" + + resource: Container + offset: Coordinate + tip: Tip + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +# --------------------------------------------------------------------------- +# 96-head operations +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class PickupTipRack: + """Pick up tips from a tip rack using the 96-head.""" + + resource: TipRack + offset: Coordinate + tips: Sequence[Optional[Tip]] + + +@dataclass(frozen=True) +class DropTipRack: + """Drop tips to a tip rack or trash using the 96-head.""" + + resource: Union[TipRack, Trash] + offset: Coordinate + + +@dataclass(frozen=True) +class MultiHeadAspirationPlate: + """Aspirate from wells in a plate using the 96-head.""" + + wells: List[Well] + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class MultiHeadDispensePlate: + """Dispense to wells in a plate using the 96-head.""" + + wells: List[Well] + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class MultiHeadAspirationContainer: + """Aspirate from a single container (trough) using the 96-head.""" + + container: Container + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] + + +@dataclass(frozen=True) +class MultiHeadDispenseContainer: + """Dispense to a single container (trough) using the 96-head.""" + + container: Container + offset: Coordinate + tips: Sequence[Optional[Tip]] + volume: float + flow_rate: Optional[float] + liquid_height: Optional[float] + blow_out_air_volume: Optional[float] + mix: Optional[Mix] diff --git a/pylabrobot/capabilities/liquid_handling/utils.py b/pylabrobot/capabilities/liquid_handling/utils.py new file mode 100644 index 00000000000..ace3173eaf0 --- /dev/null +++ b/pylabrobot/capabilities/liquid_handling/utils.py @@ -0,0 +1,77 @@ +"""Utility functions for liquid handling channel spacing.""" + +from typing import List + +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.resource import Resource + +GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS = 9 +MIN_SPACING_BETWEEN_CHANNELS = GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS +# minimum spacing between the edge of the container and the center of channel +MIN_SPACING_EDGE = 1.0 + + +def _get_centers_with_margin(dim_size: float, n: int, margin: float, min_spacing: float): + """Get the centers of the channels with a minimum margin on the edges.""" + if dim_size < margin * 2 + (n - 1) * min_spacing: + raise ValueError("Resource is too small to space channels.") + if dim_size - (n - 1) * min_spacing <= min_spacing * 2: + remaining_space = dim_size - (n - 1) * min_spacing - margin * 2 + return [margin + remaining_space / 2 + i * min_spacing for i in range(n)] + return [(i + 1) * dim_size / (n + 1) for i in range(n)] + + +def get_wide_single_resource_liquid_op_offsets( + resource: Resource, + num_channels: int, + min_spacing: float = GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS, +) -> List[Coordinate]: + resource_size = resource.get_absolute_size_y() + centers = list( + reversed( + _get_centers_with_margin( + dim_size=resource_size, + n=num_channels, + margin=MIN_SPACING_EDGE, + min_spacing=min_spacing, + ) + ) + ) # reverse because channels are from back to front + + # offsets are relative to the center of the resource, but above we computed them wrt lfb + # so we need to subtract the center of the resource + # also, offsets are in absolute space, so we need to rotate the center + return [ + Coordinate( + x=0, + y=c - resource.center().rotated(resource.get_absolute_rotation()).y, + z=0, + ) + for c in centers + ] + + +def get_tight_single_resource_liquid_op_offsets( + resource: Resource, + num_channels: int, + min_spacing: float = GENERIC_LH_MIN_SPACING_BETWEEN_CHANNELS, +) -> List[Coordinate]: + channel_space = (num_channels - 1) * min_spacing + + min_y = (resource.get_absolute_size_y() - channel_space) / 2 + if min_y < MIN_SPACING_EDGE: + raise ValueError("Resource is too small to space channels.") + + centers = [min_y + i * min_spacing for i in range(num_channels)][::-1] + + # offsets are relative to the center of the resource, but above we computed them wrt lfb + # so we need to subtract the center of the resource + # also, offsets are in absolute space, so we need to rotate the center + return [ + Coordinate( + x=0, + y=c - resource.center().rotated(resource.get_absolute_rotation()).y, + z=0, + ) + for c in centers + ] diff --git a/pylabrobot/legacy/liquid_handling/liquid_handler.py b/pylabrobot/legacy/liquid_handling/liquid_handler.py index 3159a828312..baf756d5eab 100644 --- a/pylabrobot/legacy/liquid_handling/liquid_handler.py +++ b/pylabrobot/legacy/liquid_handling/liquid_handler.py @@ -8,6 +8,7 @@ import logging import unittest.mock import warnings +from dataclasses import dataclass, field from typing import ( Any, Awaitable, @@ -21,9 +22,50 @@ Set, Tuple, Union, - cast, ) +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.head96 import Head96Capability +from pylabrobot.capabilities.liquid_handling.head96_backend import ( + Head96Backend as _NewHead96Backend, +) +from pylabrobot.capabilities.liquid_handling.pip import PIP +from pylabrobot.capabilities.liquid_handling.pip_backend import ( + PIPBackend as _NewLHBackend, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + Aspiration as _NewAspiration, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + Dispense as _NewDispense, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + DropTipRack as _NewDropTipRack, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + Mix as _NewMix, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + MultiHeadAspirationContainer as _NewMHAC, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + MultiHeadAspirationPlate as _NewMHAP, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + MultiHeadDispenseContainer as _NewMHDC, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + MultiHeadDispensePlate as _NewMHDP, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + Pickup as _NewPickup, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + PickupTipRack as _NewPickupTipRack, +) +from pylabrobot.capabilities.liquid_handling.standard import ( + TipDrop as _NewTipDrop, +) from pylabrobot.legacy.liquid_handling.errors import ChannelizedError from pylabrobot.legacy.liquid_handling.strictness import ( Strictness, @@ -31,7 +73,6 @@ ) from pylabrobot.legacy.liquid_handling.utils import ( get_tight_single_resource_liquid_op_offsets, - get_wide_single_resource_liquid_op_offsets, ) from pylabrobot.legacy.machines.machine import Machine, need_setup_finished from pylabrobot.legacy.plate_reading import PlateReader @@ -53,10 +94,7 @@ TipTracker, Trash, Well, - does_tip_tracking, - does_volume_tracking, ) -from pylabrobot.resources.errors import HasTipError from pylabrobot.resources.rotation import Rotation from pylabrobot.serializer import deserialize, serialize @@ -88,10 +126,200 @@ ] +def _convert_mix(new_mix) -> Optional[Mix]: + """Convert a new-style Mix to a legacy Mix.""" + if new_mix is None: + return None + return Mix(volume=new_mix.volume, repetitions=new_mix.repetitions, flow_rate=new_mix.flow_rate) + + class BlowOutVolumeError(Exception): pass +# --------------------------------------------------------------------------- +# Legacy → new adapters +# --------------------------------------------------------------------------- + + +@dataclass +class _DictBackendParams(BackendParams): + """Wraps legacy **backend_kwargs into a BackendParams for the new capability interface.""" + + kwargs: Dict[str, Any] = field(default_factory=dict) + + +class _LHAdapter(_NewLHBackend): + """Adapts legacy LiquidHandlerBackend to new LiquidHandlerBackend.""" + + def __init__(self, legacy: LiquidHandlerBackend): + self._legacy = legacy + + @property + def num_channels(self) -> int: + return self._legacy.num_channels + + async def pick_up_tips( + self, + ops: List[_NewPickup], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + legacy_ops = [Pickup(resource=op.resource, offset=op.offset, tip=op.tip) for op in ops] + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.pick_up_tips(ops=legacy_ops, use_channels=use_channels, **kw) + + async def drop_tips( + self, + ops: List[_NewTipDrop], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + legacy_ops = [Drop(resource=op.resource, offset=op.offset, tip=op.tip) for op in ops] + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.drop_tips(ops=legacy_ops, use_channels=use_channels, **kw) + + async def aspirate( + self, + ops: List[_NewAspiration], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + legacy_ops = [ + SingleChannelAspiration( + resource=op.resource, + offset=op.offset, + tip=op.tip, + volume=op.volume, + flow_rate=op.flow_rate, + liquid_height=op.liquid_height, + blow_out_air_volume=op.blow_out_air_volume, + mix=_convert_mix(op.mix), + ) + for op in ops + ] + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.aspirate(ops=legacy_ops, use_channels=use_channels, **kw) + + async def dispense( + self, + ops: List[_NewDispense], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + legacy_ops = [ + SingleChannelDispense( + resource=op.resource, + offset=op.offset, + tip=op.tip, + volume=op.volume, + flow_rate=op.flow_rate, + liquid_height=op.liquid_height, + blow_out_air_volume=op.blow_out_air_volume, + mix=_convert_mix(op.mix), + ) + for op in ops + ] + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.dispense(ops=legacy_ops, use_channels=use_channels, **kw) + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + return self._legacy.can_pick_up_tip(channel_idx, tip) + + async def request_tip_presence(self) -> List[Optional[bool]]: + return await self._legacy.request_tip_presence() + + +class _Head96Adapter(_NewHead96Backend): + """Adapts legacy LiquidHandlerBackend to new Head96Backend.""" + + def __init__(self, legacy: LiquidHandlerBackend): + self._legacy = legacy + + async def pick_up_tips96( + self, pickup: _NewPickupTipRack, backend_params: Optional[BackendParams] = None + ): + legacy_pickup = PickupTipRack(resource=pickup.resource, offset=pickup.offset, tips=pickup.tips) + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.pick_up_tips96(pickup=legacy_pickup, **kw) + + async def drop_tips96( + self, drop: _NewDropTipRack, backend_params: Optional[BackendParams] = None + ): + legacy_drop = DropTipRack(resource=drop.resource, offset=drop.offset) + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.drop_tips96(drop=legacy_drop, **kw) + + async def aspirate96( + self, + aspiration: Union[_NewMHAP, _NewMHAC], + backend_params: Optional[BackendParams] = None, + ): + if isinstance(aspiration, _NewMHAP): + legacy_asp: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] = ( + MultiHeadAspirationPlate( + wells=aspiration.wells, + offset=aspiration.offset, + tips=aspiration.tips, + volume=aspiration.volume, + flow_rate=aspiration.flow_rate, + liquid_height=aspiration.liquid_height, + blow_out_air_volume=aspiration.blow_out_air_volume, + mix=_convert_mix(aspiration.mix), + ) + ) + else: + legacy_asp = MultiHeadAspirationContainer( + container=aspiration.container, + offset=aspiration.offset, + tips=aspiration.tips, + volume=aspiration.volume, + flow_rate=aspiration.flow_rate, + liquid_height=aspiration.liquid_height, + blow_out_air_volume=aspiration.blow_out_air_volume, + mix=_convert_mix(aspiration.mix), + ) + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.aspirate96(aspiration=legacy_asp, **kw) + + async def dispense96( + self, + dispense: Union[_NewMHDP, _NewMHDC], + backend_params: Optional[BackendParams] = None, + ): + if isinstance(dispense, _NewMHDP): + legacy_disp: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer] = ( + MultiHeadDispensePlate( + wells=dispense.wells, + offset=dispense.offset, + tips=dispense.tips, + volume=dispense.volume, + flow_rate=dispense.flow_rate, + liquid_height=dispense.liquid_height, + blow_out_air_volume=dispense.blow_out_air_volume, + mix=_convert_mix(dispense.mix), + ) + ) + else: + legacy_disp = MultiHeadDispenseContainer( + container=dispense.container, + offset=dispense.offset, + tips=dispense.tips, + volume=dispense.volume, + flow_rate=dispense.flow_rate, + liquid_height=dispense.liquid_height, + blow_out_air_volume=dispense.blow_out_air_volume, + mix=_convert_mix(dispense.mix), + ) + kw = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + await self._legacy.dispense96(dispense=legacy_disp, **kw) + + +# --------------------------------------------------------------------------- +# LiquidHandler +# --------------------------------------------------------------------------- + + class LiquidHandler(Resource, Machine): """ Front end for liquid handlers. @@ -135,7 +363,9 @@ def __init__( self.head96: Dict[int, TipTracker] = {} self._default_use_channels: Optional[List[int]] = None - self._blow_out_air_volume: Optional[List[Optional[float]]] = None + # New capability instances — created during setup() + self._lh_cap: Optional[PIP] = None + self._head96_cap: Optional[Head96Capability] = None # Default offset applied to all 96-head operations. Any offset passed to a 96-head method is # added to this value. @@ -166,13 +396,19 @@ async def setup(self, **backend_kwargs): self.backend.set_heads(head=self.head, head96=self.head96) await super().setup(**backend_kwargs) - self.head = {c: TipTracker(thing=f"Channel {c}") for c in range(self.backend.num_channels)} + # Create capabilities with adapter backends + self._lh_cap = PIP(backend=_LHAdapter(self.backend)) + await self._lh_cap._on_setup() - self.head96 = ( - {c: TipTracker(thing=f"Channel {c}") for c in range(96)} - if self.backend.head96_installed - else {} - ) + if self.backend.head96_installed: + self._head96_cap = Head96Capability( + backend=_Head96Adapter(self.backend), + ) + await self._head96_cap._on_setup() + + # Alias head trackers from capabilities for backward compat + self.head = self._lh_cap.head + self.head96 = self._head96_cap.head if self._head96_cap is not None else {} self.backend.set_heads(head=self.head, head96=self.head96 or None) @@ -439,49 +675,7 @@ async def pick_up_tips( offsets=offsets, ) - not_tip_spots = [ts for ts in tip_spots if not isinstance(ts, TipSpot)] - if len(not_tip_spots) > 0: - raise TypeError(f"Resources must be `TipSpot`s, got {not_tip_spots}") - - # fix arguments - use_channels = use_channels or self._default_use_channels or list(range(len(tip_spots))) - assert len(set(use_channels)) == len(use_channels), "Channels must be unique." - - tips = [tip_spot.get_tip() for tip_spot in tip_spots] - - if not all( - self.backend.can_pick_up_tip(channel, tip) for channel, tip in zip(use_channels, tips) - ): - cannot = [ - channel - for channel, tip in zip(use_channels, tips) - if not self.backend.can_pick_up_tip(channel, tip) - ] - raise RuntimeError(f"Cannot pick up tips on channels {cannot}.") - - # expand default arguments - offsets = offsets or [Coordinate.zero()] * len(tip_spots) - - # checks self._assert_resources_exist(tip_spots) - self._make_sure_channels_exist(use_channels) - assert len(tip_spots) == len(offsets) == len(use_channels), ( - "Number of tips and offsets and use_channels must be equal." - ) - - # create operations - pickups = [ - Pickup(resource=tip_spot, offset=offset, tip=tip) - for tip_spot, offset, tip in zip(tip_spots, offsets, tips) - ] - - # queue operations on the trackers - for channel, op in zip(use_channels, pickups): - if self.head[channel].has_tip: - raise HasTipError("Channel has tip") - if does_tip_tracking() and not op.resource.tracker.is_disabled: - op.resource.tracker.remove_tip() - self.head[channel].add_tip(op.tip, origin=op.resource, commit=False) # fix the backend kwargs extras = self._check_args( @@ -493,33 +687,13 @@ async def pick_up_tips( for extra in extras: del backend_kwargs[extra] - # actually pick up the tips - error: Optional[BaseException] = None - try: - await self.backend.pick_up_tips(ops=pickups, use_channels=use_channels, **backend_kwargs) - except BaseException as e: - error = e - - # determine which channels were successful - successes = [error is None] * len(pickups) - if error is not None: - try: - tip_presence = await self.backend.request_tip_presence() - successes = [tip_presence[ch] is True for ch in use_channels] - except Exception as tip_presence_error: - if not isinstance(tip_presence_error, NotImplementedError): - logger.warning("Failed to query tip presence after error: %s", tip_presence_error) - if isinstance(error, ChannelizedError): - successes = [channel_idx not in error.errors for channel_idx in use_channels] - - # commit or rollback the state trackers - for channel, op, success in zip(use_channels, pickups, successes): - if does_tip_tracking() and not op.resource.tracker.is_disabled: - (op.resource.tracker.commit if success else op.resource.tracker.rollback)() - (self.head[channel].commit if success else self.head[channel].rollback)() - - if error is not None: - raise error + assert self._lh_cap is not None + await self._lh_cap.pick_up_tips( + tip_spots=tip_spots, + use_channels=use_channels, + offsets=offsets, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) def get_mounted_tips(self) -> List[Optional[Tip]]: """Get the tips currently mounted on the head. @@ -588,46 +762,7 @@ async def drop_tips( allow_nonzero_volume=allow_nonzero_volume, ) - not_tip_spots = [ts for ts in tip_spots if not isinstance(ts, (TipSpot, Trash))] - if len(not_tip_spots) > 0: - raise TypeError(f"Resources must be `TipSpot`s or Trash, got {not_tip_spots}") - - # fix arguments - use_channels = use_channels or self._default_use_channels or list(range(len(tip_spots))) - assert len(set(use_channels)) == len(use_channels), "Channels must be unique." - - tips = [] - for channel in use_channels: - tip = self.head[channel].get_tip() - if tip.tracker.get_used_volume() > 0 and not allow_nonzero_volume: - raise RuntimeError(f"Cannot drop tip with volume {tip.tracker.get_used_volume()}") - tips.append(tip) - - # expand default arguments - offsets = offsets or [Coordinate.zero()] * len(tip_spots) - - # checks self._assert_resources_exist(tip_spots) - self._make_sure_channels_exist(use_channels) - assert len(tip_spots) == len(offsets) == len(use_channels) == len(tips), ( - "Number of channels and offsets and use_channels and tips must be equal." - ) - - # create operations - drops = [ - Drop(resource=tip_spot, offset=offset, tip=tip) - for tip_spot, tip, offset in zip(tip_spots, tips, offsets) - ] - - # queue operations on the trackers - for channel, op in zip(use_channels, drops): - if ( - does_tip_tracking() - and isinstance(op.resource, TipSpot) - and not op.resource.tracker.is_disabled - ): - op.resource.tracker.add_tip(op.tip, commit=False) - self.head[channel].remove_tip() # fix the backend kwargs extras = self._check_args( @@ -639,37 +774,14 @@ async def drop_tips( for extra in extras: del backend_kwargs[extra] - # actually drop the tips - error: Optional[BaseException] = None - try: - await self.backend.drop_tips(ops=drops, use_channels=use_channels, **backend_kwargs) - except BaseException as e: - error = e - - # determine which channels were successful - successes = [error is None] * len(drops) - if error is not None: - try: - tip_presence = await self.backend.request_tip_presence() - successes = [tip_presence[ch] is False for ch in use_channels] - except Exception as tip_presence_error: - if not isinstance(tip_presence_error, NotImplementedError): - logger.warning("Failed to query tip presence after error: %s", tip_presence_error) - if isinstance(error, ChannelizedError): - successes = [channel_idx not in error.errors for channel_idx in use_channels] - - # commit or rollback the state trackers - for channel, op, success in zip(use_channels, drops, successes): - if ( - does_tip_tracking() - and isinstance(op.resource, TipSpot) - and not op.resource.tracker.is_disabled - ): - (op.resource.tracker.commit if success else op.resource.tracker.rollback)() - (self.head[channel].commit if success else self.head[channel].rollback)() - - if error is not None: - raise error + assert self._lh_cap is not None + await self._lh_cap.drop_tips( + tip_spots=tip_spots, + use_channels=use_channels, + offsets=offsets, + allow_nonzero_volume=allow_nonzero_volume, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) async def return_tips( self, @@ -902,98 +1014,7 @@ async def aspirate( blow_out_air_volume=blow_out_air_volume, ) - self._check_containers(resources) - - use_channels = use_channels or self._default_use_channels or list(range(len(resources))) - assert len(set(use_channels)) == len(use_channels), "Channels must be unique." - - # expand default arguments - offsets = offsets or [Coordinate.zero()] * len(use_channels) - flow_rates = flow_rates or [None] * len(use_channels) - liquid_height = liquid_height or [None] * len(use_channels) - blow_out_air_volume = blow_out_air_volume or [None] * len(use_channels) - - # Convert everything to floats to handle exotic number types - vols = [float(v) for v in vols] - flow_rates = [float(fr) if fr is not None else None for fr in flow_rates] - liquid_height = [float(lh) if lh is not None else None for lh in liquid_height] - blow_out_air_volume = [float(bav) if bav is not None else None for bav in blow_out_air_volume] - - self._blow_out_air_volume = blow_out_air_volume - tips = [self.head[channel].get_tip() for channel in use_channels] - - # Checks - for resource in resources: - if isinstance(resource.parent, Plate) and resource.parent.has_lid(): - raise ValueError("Aspirating from a well with a lid is not supported.") - - self._make_sure_channels_exist(use_channels) - for n, p in [ - ("resources", resources), - ("vols", vols), - ("offsets", offsets), - ("flow_rates", flow_rates), - ("liquid_height", liquid_height), - ("blow_out_air_volume", blow_out_air_volume), - ]: - if len(p) != len(use_channels): - raise ValueError( - f"Length of {n} must match length of use_channels: {len(p)} != {len(use_channels)}" - ) - - # If the user specified a single resource, but multiple channels to use, we will assume they - # want to space the channels evenly across the resource. Note that offsets are relative to the - # center of the resource. - if len(set(resources)) == 1: - resource = resources[0] - resources = [resource] * len(use_channels) - if spread == "tight": - center_offsets = get_tight_single_resource_liquid_op_offsets( - resource=resource, num_channels=len(use_channels) - ) - elif spread == "wide": - center_offsets = get_wide_single_resource_liquid_op_offsets( - resource=resource, num_channels=len(use_channels) - ) - elif spread == "custom": - center_offsets = [Coordinate.zero()] * len(use_channels) - else: - raise ValueError("Invalid value for 'spread'. Must be 'tight', 'wide', or 'custom'.") - - # add user defined offsets to the computed centers - offsets = [c + o for c, o in zip(center_offsets, offsets)] - - # create operations - aspirations = [ - SingleChannelAspiration( - resource=r, - volume=v, - offset=o, - flow_rate=fr, - liquid_height=lh, - tip=t, - blow_out_air_volume=bav, - mix=m, - ) - for r, v, o, fr, lh, t, bav, m in zip( - resources, - vols, - offsets, - flow_rates, - liquid_height, - tips, - blow_out_air_volume, - mix or [None] * len(use_channels), # type: ignore - ) - ] - - # queue the operations on the resource (source) and mounted tips (destination) trackers - for op in aspirations: - if does_volume_tracking(): - if not op.resource.tracker.is_disabled: - op.resource.tracker.remove_liquid(op.volume) - op.tip.tracker.add_liquid(volume=op.volume) - + # fix the backend kwargs extras = self._check_args( self.backend.aspirate, backend_kwargs, @@ -1003,28 +1024,21 @@ async def aspirate( for extra in extras: del backend_kwargs[extra] - # actually aspirate the liquid - error: Optional[Exception] = None - try: - await self.backend.aspirate(ops=aspirations, use_channels=use_channels, **backend_kwargs) - except Exception as e: - error = e - - # determine which channels were successful - successes = [error is None] * len(aspirations) - if error is not None and isinstance(error, ChannelizedError): - successes = [channel_idx not in error.errors for channel_idx in use_channels] - - # commit or rollback the state trackers - for channel, op, success in zip(use_channels, aspirations, successes): - if does_volume_tracking(): - if not op.resource.tracker.is_disabled: - (op.resource.tracker.commit if success else op.resource.tracker.rollback)() - tip_volume_tracker = self.head[channel].get_tip().tracker - (tip_volume_tracker.commit if success else tip_volume_tracker.rollback)() - - if error is not None: - raise error + assert self._lh_cap is not None + await self._lh_cap.aspirate( + resources=resources, + vols=vols, + use_channels=use_channels, + flow_rates=flow_rates, + offsets=offsets, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + spread=spread, + mix=[_NewMix(volume=m.volume, repetitions=m.repetitions, flow_rate=m.flow_rate) for m in mix] + if mix is not None + else None, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) @need_setup_finished async def dispense( @@ -1103,108 +1117,6 @@ async def dispense( blow_out_air_volume=blow_out_air_volume, ) - # If the user specified a single resource, but multiple channels to use, we will assume they - # want to space the channels evenly across the resource. Note that offsets are relative to the - # center of the resource. - - self._check_containers(resources) - - use_channels = use_channels or self._default_use_channels or list(range(len(resources))) - assert len(set(use_channels)) == len(use_channels), "Channels must be unique." - - # expand default arguments - offsets = offsets or [Coordinate.zero()] * len(use_channels) - flow_rates = flow_rates or [None] * len(use_channels) - liquid_height = liquid_height or [None] * len(use_channels) - blow_out_air_volume = blow_out_air_volume or [None] * len(use_channels) - - # Convert everything to floats to handle exotic number types - vols = [float(v) for v in vols] - flow_rates = [float(fr) if fr is not None else None for fr in flow_rates] - liquid_height = [float(lh) if lh is not None else None for lh in liquid_height] - blow_out_air_volume = [float(bav) if bav is not None else None for bav in blow_out_air_volume] - - # If the user specified a single resource, but multiple channels to use, we will assume they - # want to space the channels evenly across the resource. Note that offsets are relative to the - # center of the resource. - if len(set(resources)) == 1: - resource = resources[0] - resources = [resource] * len(use_channels) - if spread == "tight": - center_offsets = get_tight_single_resource_liquid_op_offsets( - resource=resource, num_channels=len(use_channels) - ) - elif spread == "wide": - center_offsets = get_wide_single_resource_liquid_op_offsets( - resource=resource, num_channels=len(use_channels) - ) - elif spread == "custom": - center_offsets = [Coordinate.zero()] * len(use_channels) - else: - raise ValueError("Invalid value for 'spread'. Must be 'tight', 'wide', or 'custom'.") - - # add user defined offsets to the computed centers - offsets = [c + o for c, o in zip(center_offsets, offsets)] - - tips = [self.head[channel].get_tip() for channel in use_channels] - - # Check the blow out air volume with what was aspirated - if does_volume_tracking(): - if any(bav is not None and bav != 0.0 for bav in blow_out_air_volume): - if self._blow_out_air_volume is None: - raise BlowOutVolumeError("No blowout volume was aspirated.") - for requested_bav, done_bav in zip(blow_out_air_volume, self._blow_out_air_volume): - if requested_bav is not None and done_bav is not None and requested_bav > done_bav: - raise BlowOutVolumeError("Blowout volume is larger than aspirated volume") - - for resource in resources: - if isinstance(resource.parent, Plate) and resource.parent.has_lid(): - raise ValueError("Dispensing to plate with lid") - - for n, p in [ - ("resources", resources), - ("vols", vols), - ("offsets", offsets), - ("flow_rates", flow_rates), - ("liquid_height", liquid_height), - ("blow_out_air_volume", blow_out_air_volume), - ]: - if len(p) != len(use_channels): - raise ValueError( - f"Length of {n} must match length of use_channels: {len(p)} != {len(use_channels)}" - ) - - # create operations - dispenses = [ - SingleChannelDispense( - resource=r, - volume=v, - offset=o, - flow_rate=fr, - liquid_height=lh, - tip=t, - blow_out_air_volume=bav, - mix=m, - ) - for r, v, o, fr, lh, t, bav, m in zip( - resources, - vols, - offsets, - flow_rates, - liquid_height, - tips, - blow_out_air_volume, - mix or [None] * len(use_channels), # type: ignore - ) - ] - - # queue the operations on the resource (source) and mounted tips (destination) trackers - for op in dispenses: - if does_volume_tracking(): - if not op.resource.tracker.is_disabled: - op.resource.tracker.add_liquid(volume=op.volume) - op.tip.tracker.remove_liquid(op.volume) - # fix the backend kwargs extras = self._check_args( self.backend.dispense, @@ -1215,31 +1127,21 @@ async def dispense( for extra in extras: del backend_kwargs[extra] - # actually dispense the liquid - error: Optional[Exception] = None - try: - await self.backend.dispense(ops=dispenses, use_channels=use_channels, **backend_kwargs) - except Exception as e: - error = e - - # determine which channels were successful - successes = [error is None] * len(dispenses) - if error is not None and isinstance(error, ChannelizedError): - successes = [channel_idx not in error.errors for channel_idx in use_channels] - - # commit or rollback the state trackers - for channel, op, success in zip(use_channels, dispenses, successes): - if does_volume_tracking(): - if not op.resource.tracker.is_disabled: - (op.resource.tracker.commit if success else op.resource.tracker.rollback)() - tip_volume_tracker = self.head[channel].get_tip().tracker - (tip_volume_tracker.commit if success else tip_volume_tracker.rollback)() - - if any(bav is not None for bav in blow_out_air_volume): - self._blow_out_air_volume = None - - if error is not None: - raise error + assert self._lh_cap is not None + await self._lh_cap.dispense( + resources=resources, + vols=vols, + use_channels=use_channels, + flow_rates=flow_rates, + offsets=offsets, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + spread=spread, + mix=[_NewMix(volume=m.volume, repetitions=m.repetitions, flow_rate=m.flow_rate) for m in mix] + if mix is not None + else None, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) async def transfer( self, @@ -1354,11 +1256,15 @@ def use_channels(self, channels: List[int]): """ self._default_use_channels = channels + if self._lh_cap is not None: + self._lh_cap._default_use_channels = channels try: yield finally: self._default_use_channels = None + if self._lh_cap is not None: + self._lh_cap._default_use_channels = None @contextlib.asynccontextmanager async def use_tips( @@ -1444,46 +1350,18 @@ async def pick_up_tips96( offset=offset, ) - if not isinstance(tip_rack, TipRack): - raise TypeError(f"Resource must be a TipRack, got {tip_rack}") - if not tip_rack.num_items == 96: - raise ValueError("Tip rack must have 96 tips") - extras = self._check_args( self.backend.pick_up_tips96, backend_kwargs, default={"pickup"}, strictness=get_strictness() ) for extra in extras: del backend_kwargs[extra] - # queue operation on all tip trackers - tips: List[Optional[Tip]] = [] - for i, tip_spot in enumerate(tip_rack.get_all_items()): - if not does_tip_tracking() and self.head96[i].has_tip: - self.head96[i].remove_tip() - # only add tips where there is one present. - # it's possible only some tips are present in the tip rack. - if tip_spot.has_tip(): - self.head96[i].add_tip(tip_spot.get_tip(), origin=tip_spot, commit=False) - tips.append(tip_spot.get_tip()) - else: - tips.append(None) - if does_tip_tracking() and not tip_spot.tracker.is_disabled and tip_spot.has_tip(): - tip_spot.tracker.remove_tip() - - pickup_operation = PickupTipRack(resource=tip_rack, offset=offset, tips=tips) - try: - await self.backend.pick_up_tips96(pickup=pickup_operation, **backend_kwargs) - except Exception as error: - for i, tip_spot in enumerate(tip_rack.get_all_items()): - if does_tip_tracking() and not tip_spot.tracker.is_disabled: - tip_spot.tracker.rollback() - self.head96[i].rollback() - raise error - else: - for i, tip_spot in enumerate(tip_rack.get_all_items()): - if does_tip_tracking() and not tip_spot.tracker.is_disabled: - tip_spot.tracker.commit() - self.head96[i].commit() + assert self._head96_cap is not None + await self._head96_cap.pick_up_tips( + tip_rack=tip_rack, + offset=offset, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) async def drop_tips96( self, @@ -1522,50 +1400,19 @@ async def drop_tips96( allow_nonzero_volume=allow_nonzero_volume, ) - if not isinstance(resource, (TipRack, Trash)): - raise TypeError(f"Resource must be a TipRack or Trash, got {resource}") - if isinstance(resource, TipRack) and not resource.num_items == 96: - raise ValueError("Tip rack must have 96 tips") - extras = self._check_args( self.backend.drop_tips96, backend_kwargs, default={"drop"}, strictness=get_strictness() ) for extra in extras: del backend_kwargs[extra] - # queue operation on all tip trackers - for i in range(96): - # it's possible not every channel on this head has a tip. - if not self.head96[i].has_tip: - continue - tip = self.head96[i].get_tip() - if tip.tracker.get_used_volume() > 0 and not allow_nonzero_volume and does_volume_tracking(): - error = f"Cannot drop tip with volume {tip.tracker.get_used_volume()} on channel {i}" - raise RuntimeError(error) - if isinstance(resource, TipRack): - tip_spot = resource.get_item(i) - if does_tip_tracking() and not tip_spot.tracker.is_disabled: - tip_spot.tracker.add_tip(tip, commit=False) - self.head96[i].remove_tip() - - drop_operation = DropTipRack(resource=resource, offset=offset) - try: - await self.backend.drop_tips96(drop=drop_operation, **backend_kwargs) - except Exception as e: - for i in range(96): - if isinstance(resource, TipRack): - tip_spot = resource.get_item(i) - if does_tip_tracking() and not tip_spot.tracker.is_disabled: - tip_spot.tracker.rollback() - self.head96[i].rollback() - raise e - else: - for i in range(96): - if isinstance(resource, TipRack): - tip_spot = resource.get_item(i) - if does_tip_tracking() and not tip_spot.tracker.is_disabled: - tip_spot.tracker.commit() - self.head96[i].commit() + assert self._head96_cap is not None + await self._head96_cap.drop_tips( + resource=resource, + offset=offset, + allow_nonzero_volume=allow_nonzero_volume, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) def _get_96_head_origin_tip_rack(self) -> Optional[TipRack]: """Get the tip rack where the tips on the 96 head were picked up. If no tips were picked up, @@ -1707,111 +1554,25 @@ async def aspirate96( mix=mix, ) - if not ( - isinstance(resource, (Plate, Container)) - or (isinstance(resource, list) and all(isinstance(w, Well) for w in resource)) - ): - raise TypeError(f"Resource must be a Plate, Container, or list of Wells, got {resource}") - extras = self._check_args( self.backend.aspirate96, backend_kwargs, default={"aspiration"}, strictness=get_strictness() ) for extra in extras: del backend_kwargs[extra] - tips = [channel.get_tip() if channel.has_tip else None for channel in self.head96.values()] - aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer] - - # Convert everything to floats to handle exotic number types - volume = float(volume) - flow_rate = float(flow_rate) if flow_rate is not None else None - blow_out_air_volume = float(blow_out_air_volume) if blow_out_air_volume is not None else None - - # Convert Plate to either one Container (single well) or a list of Wells - containers: Sequence[Container] - if isinstance(resource, Plate): - if resource.has_lid(): - raise ValueError("Aspirating from plate with lid") - containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] - elif isinstance(resource, Container): - containers = [resource] - elif isinstance(resource, list) and all(isinstance(w, Well) for w in resource): - containers = resource - else: - raise TypeError( - f"Resource must be a Plate, Container, or list of Wells, got {type(resource)} " - f" for {resource}" - ) - - if len(containers) == 1: # single container - container = containers[0] - if not self._check_96_head_fits_in_container(container): - raise ValueError("Container too small to accommodate 96 head") - - for tip in tips: - if tip is None: - continue - - if not container.tracker.is_disabled and does_volume_tracking(): - container.tracker.remove_liquid(volume=volume) - tip.tracker.add_liquid(volume=volume) - - aspiration = MultiHeadAspirationContainer( - container=container, - volume=volume, - offset=offset, - flow_rate=flow_rate, - tips=tips, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - mix=mix, - ) - else: # multiple containers - # ensure that wells are all in the same plate - plate = containers[0].parent - for well in containers: - if well.parent != plate: - raise ValueError("All wells must be in the same plate") - - if not len(containers) == 96: - raise ValueError(f"aspirate96 expects 96 containers when a list, got {len(containers)}") - - for well, tip in zip(containers, tips): - if tip is None: - continue - - if not well.tracker.is_disabled and does_volume_tracking(): - well.tracker.remove_liquid(volume=volume) - tip.tracker.add_liquid(volume=volume) - - aspiration = MultiHeadAspirationPlate( - wells=cast(List[Well], containers), - volume=volume, - offset=offset, - flow_rate=flow_rate, - tips=tips, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - mix=mix, - ) - - try: - await self.backend.aspirate96(aspiration=aspiration, **backend_kwargs) - except Exception: - for tip in tips: - if tip is not None: - tip.tracker.rollback() - for container in containers: - if does_volume_tracking() and not container.tracker.is_disabled: - container.tracker.rollback() - raise - else: - for tip in tips: - if tip is not None: - tip.tracker.commit() - for container in containers: - if does_volume_tracking() and not container.tracker.is_disabled: - container.tracker.commit() + assert self._head96_cap is not None + await self._head96_cap.aspirate( + resource=resource, + volume=volume, + offset=offset, + flow_rate=flow_rate, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=_NewMix(volume=mix.volume, repetitions=mix.repetitions, flow_rate=mix.flow_rate) + if mix is not None + else None, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) async def dispense96( self, @@ -1855,117 +1616,25 @@ async def dispense96( mix=mix, ) - if not ( - isinstance(resource, (Plate, Container)) - or (isinstance(resource, list) and all(isinstance(w, Well) for w in resource)) - ): - raise TypeError(f"Resource must be a Plate, Container, or list of Wells, got {resource}") - extras = self._check_args( self.backend.dispense96, backend_kwargs, default={"dispense"}, strictness=get_strictness() ) for extra in extras: del backend_kwargs[extra] - tips = [channel.get_tip() if channel.has_tip else None for channel in self.head96.values()] - dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer] - - # Convert everything to floats to handle exotic number types - volume = float(volume) - flow_rate = float(flow_rate) if flow_rate is not None else None - blow_out_air_volume = float(blow_out_air_volume) if blow_out_air_volume is not None else None - - # Convert Plate to either one Container (single well) or a list of Wells - containers: Sequence[Container] - if isinstance(resource, Plate): - if resource.has_lid(): - raise ValueError("Dispensing to plate with lid is not possible. Remove the lid first.") - containers = resource.get_all_items() if resource.num_items > 1 else [resource.get_item(0)] - elif isinstance(resource, Container): - containers = [resource] - elif isinstance(resource, list) and all(isinstance(w, Well) for w in resource): - containers = resource - else: - raise TypeError( - f"Resource must be a Plate, Container, or list of Wells, got {type(resource)} " - f"for {resource}" - ) - - # if we have enough liquid in the tip, remove it from the tip tracker for accounting. - # if we do not (for example because the plunger was up on tip pickup), and we - # do not have volume tracking enabled, we just ignore it. - for tip in tips: - if tip is None: - continue - - if does_volume_tracking(): - tip.tracker.remove_liquid(volume=volume) - elif tip.tracker.get_used_volume() < volume: - tip.tracker.remove_liquid(volume=min(tip.tracker.get_used_volume(), volume)) - - if len(containers) == 1: # single container - container = containers[0] - if not self._check_96_head_fits_in_container(container): - raise ValueError("Container too small to accommodate 96 head") - - if not container.tracker.is_disabled and does_volume_tracking(): - container.tracker.add_liquid(volume=len([t for t in tips if t is not None]) * volume) - - dispense = MultiHeadDispenseContainer( - container=container, - volume=volume, - offset=offset, - flow_rate=flow_rate, - tips=tips, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - mix=mix, - ) - else: - # ensure that wells are all in the same plate - plate = containers[0].parent - for well in containers: - if well.parent != plate: - raise ValueError("All wells must be in the same plate") - - if not len(containers) == 96: - raise ValueError(f"dispense96 expects 96 wells, got {len(containers)}") - - for well, tip in zip(containers, tips): - if tip is None: - continue - - if not well.tracker.is_disabled and does_volume_tracking(): - well.tracker.add_liquid(volume=volume) - - dispense = MultiHeadDispensePlate( - wells=cast(List[Well], containers), - volume=volume, - offset=offset, - flow_rate=flow_rate, - tips=tips, - liquid_height=liquid_height, - blow_out_air_volume=blow_out_air_volume, - mix=mix, - ) - - try: - await self.backend.dispense96(dispense=dispense, **backend_kwargs) - except Exception: - for tip in tips: - if tip is not None: - tip.tracker.rollback() - for container in containers: - if does_volume_tracking() and not container.tracker.is_disabled: - container.tracker.rollback() - raise - else: - for tip in tips: - if tip is not None: - tip.tracker.commit() - for container in containers: - if does_volume_tracking() and not container.tracker.is_disabled: - container.tracker.commit() + assert self._head96_cap is not None + await self._head96_cap.dispense( + resource=resource, + volume=volume, + offset=offset, + flow_rate=flow_rate, + liquid_height=liquid_height, + blow_out_air_volume=blow_out_air_volume, + mix=_NewMix(volume=mix.volume, repetitions=mix.repetitions, flow_rate=mix.flow_rate) + if mix is not None + else None, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, + ) async def stamp( self, @@ -2002,7 +1671,7 @@ async def stamp( ), "Source and target plates must be the same shape" await self.aspirate96(resource=source, volume=volume, flow_rate=aspiration_flow_rate) - await self.dispense96(resource=source, volume=volume, flow_rate=dispense_flow_rate) + await self.dispense96(resource=target, volume=volume, flow_rate=dispense_flow_rate) async def pick_up_resource( self, From ed710c6b3aa95fc8d6123b4cae7f65be90230202 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 26 Mar 2026 22:23:19 -0700 Subject: [PATCH 16/69] Add arms module with Arm/GripperArm hierarchy, Brooks and iSWAP backends (#957) Co-authored-by: Claude Opus 4.6 (1M context) --- pylabrobot/arms/__init__.py | 4 + pylabrobot/arms/architecture.md | 81 + pylabrobot/arms/arm.py | 429 +++ pylabrobot/arms/arm_tests.py | 181 ++ pylabrobot/arms/backend.py | 212 ++ pylabrobot/arms/orientable_arm.py | 159 ++ pylabrobot/arms/standard.py | 41 + pylabrobot/brooks/__init__.py | 6 + pylabrobot/brooks/error_codes.py | 1809 +++++++++++++ pylabrobot/brooks/pf400_test.ipynb | 397 +++ pylabrobot/brooks/precise_flex.py | 1816 +++++++++++++ .../hamilton/liquid_handlers/star/core.py | 197 ++ .../liquid_handlers/star/core_test.ipynb | 259 ++ .../liquid_handlers/star/core_tests.py | 166 ++ .../hamilton/liquid_handlers/star/iswap.py | 336 +++ .../liquid_handlers/star/iswap_test.ipynb | 292 +++ .../liquid_handlers/star/iswap_tests.py | 194 ++ .../legacy/arms/precise_flex/pf_3400.py | 8 +- pylabrobot/legacy/arms/precise_flex/pf_400.py | 7 +- .../arms/precise_flex/precise_flex_backend.py | 2311 +++-------------- 20 files changed, 6943 insertions(+), 1962 deletions(-) create mode 100644 pylabrobot/arms/__init__.py create mode 100644 pylabrobot/arms/architecture.md create mode 100644 pylabrobot/arms/arm.py create mode 100644 pylabrobot/arms/arm_tests.py create mode 100644 pylabrobot/arms/backend.py create mode 100644 pylabrobot/arms/orientable_arm.py create mode 100644 pylabrobot/arms/standard.py create mode 100644 pylabrobot/brooks/__init__.py create mode 100644 pylabrobot/brooks/error_codes.py create mode 100644 pylabrobot/brooks/pf400_test.ipynb create mode 100644 pylabrobot/brooks/precise_flex.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/core.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/core_test.ipynb create mode 100644 pylabrobot/hamilton/liquid_handlers/star/core_tests.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/iswap.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/iswap_test.ipynb create mode 100644 pylabrobot/hamilton/liquid_handlers/star/iswap_tests.py diff --git a/pylabrobot/arms/__init__.py b/pylabrobot/arms/__init__.py new file mode 100644 index 00000000000..480e32242ed --- /dev/null +++ b/pylabrobot/arms/__init__.py @@ -0,0 +1,4 @@ +from .arm import * +from .backend import * +from .orientable_arm import * +from .standard import * diff --git a/pylabrobot/arms/architecture.md b/pylabrobot/arms/architecture.md new file mode 100644 index 00000000000..abe672942b8 --- /dev/null +++ b/pylabrobot/arms/architecture.md @@ -0,0 +1,81 @@ +# Arms architecture + +## Frontend hierarchy (capabilities) + +``` +_BaseArm(Capability) + │ halt(), park(), get_gripper_location() + │ resource tracking (pick_up/drop state) + │ + └── GripperArm + │ open/close_gripper, is_gripper_closed + │ pick_up/drop/move at location + │ pick_up_resource(), drop_resource(), move_resource() (convenience) + │ + └── OrientableArm + Arm with rotation. E.g. Hamilton iSWAP, PreciseFlex. + pick_up/drop/move with direction parameter +``` + +Frontend mirrors backend hierarchy exactly. +Joint-space methods are backend-only (robot-specific), accessed via `arm.backend`. + +## Backend hierarchy (capability backends) + +``` +_BaseArmBackend(CapabilityBackend) + │ halt(), park(), get_gripper_location() + │ + ├── GripperArmBackend + │ open/close_gripper, is_gripper_closed + │ pick_up/drop/move at location (no rotation) + │ + ├── OrientableGripperArmBackend + │ pick_up/drop/move with direction (float degrees) + │ + └── ArticulatedGripperArmBackend + pick_up/drop/move with full Rotation +``` + +## Mixins (backend) + +- `HasJoints` — joint-space control: pick_up/drop/move at joint position, get_joint_position +- `CanFreedrive` — freedrive (manual guidance) mode + +## Concrete implementations + +| Device | Driver | Arm Backend | Frontend | +|--------|--------|-------------|----------| +| Hamilton STAR (iSWAP) | STARDriver (shared) | `iSWAP(OrientableGripperArmBackend)` | `OrientableArm` | +| Hamilton STAR (core) | STARDriver (shared) | `CoreGripper(GripperArmBackend)` | `Arm` | +| PreciseFlex 400 | `PreciseFlexDriver` | `PreciseFlexArmBackend(OrientableGripperArmBackend, HasJoints, CanFreedrive)` | `OrientableArm` | + +## Usage + +Arms are capabilities, not devices. They are owned by a Device: + +```python +class STAR(Device): + def __init__(self, ...): + driver = STARDriver(...) + super().__init__(driver=driver) + self.iswap = OrientableArm(backend=iSWAP(driver), reference_resource=deck) + self.core_gripper = GripperArm(backend=CoreGripper(driver), reference_resource=deck) + self._capabilities = [self.iswap, self.core_gripper] +``` + +A standalone arm (like PreciseFlex) is a Device with a single arm capability: + +```python +class PreciseFlex400(Device): + def __init__(self, host, port=10100, has_rail=False, timeout=20): + driver = PreciseFlexDriver(host=host, port=port, timeout=timeout) + super().__init__(driver=driver) + backend = PreciseFlexArmBackend(driver=driver, has_rail=has_rail) + self.arm = OrientableArm(backend=backend, reference_resource=self.reference) + self._capabilities = [self.arm] + +# Joint methods accessed via backend (robot-specific): +await pf.arm.backend.move_to_joint_position({1: 0, 2: 90, 3: 45}) +await pf.arm.backend.start_freedrive_mode(free_axes=[0]) +``` diff --git a/pylabrobot/arms/arm.py b/pylabrobot/arms/arm.py new file mode 100644 index 00000000000..453e98ea510 --- /dev/null +++ b/pylabrobot/arms/arm.py @@ -0,0 +1,429 @@ +import logging +from dataclasses import dataclass +from typing import List, Literal, Optional, Tuple, Union + +from pylabrobot.arms.backend import GripperArmBackend, _BaseArmBackend +from pylabrobot.arms.standard import GripDirection, GripperLocation +from pylabrobot.capabilities.capability import BackendParams, Capability +from pylabrobot.legacy.tilting.tilter import Tilter +from pylabrobot.resources import ( + Coordinate, + Lid, + Plate, + PlateAdapter, + Resource, + ResourceHolder, + ResourceStack, + Trash, +) +from pylabrobot.resources.rotation import Rotation + +logger = logging.getLogger(__name__) + +GripOrientation = Union[GripDirection, float] + + +@dataclass +class _PickedUpState: + resource: Resource + offset: Coordinate + pickup_distance_from_top: float + resource_width: float + rotation: Rotation = Rotation() + + +class _BaseArm(Capability): + """Base class for all arm types. Not instantiated directly.""" + + def __init__(self, backend, reference_resource: Resource): + super().__init__(backend=backend) + self.backend: _BaseArmBackend = backend + self._reference_resource = reference_resource + self._picked_up: Optional[_PickedUpState] = None + self._holding_resource_width: Optional[float] = None + + async def _on_setup(self, **backend_kwargs): + await super()._on_setup(**backend_kwargs) + self._picked_up = None + self._holding_resource_width = None + + async def _on_stop(self): + await super()._on_stop() + self._picked_up = None + self._holding_resource_width = None + + def _state_updated(self): + pass + + @property + def holding(self) -> bool: + return self._holding_resource_width is not None + + def get_picked_up_resource(self) -> Optional[Resource]: + if self._picked_up is not None: + return self._picked_up.resource + return None + + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + """Stop any ongoing movement of the arm.""" + return await self.backend.halt(backend_params=backend_params) + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Park the arm to its default position.""" + return await self.backend.park(backend_params=backend_params) + + async def get_gripper_location( + self, backend_params: Optional[BackendParams] = None + ) -> GripperLocation: + """Get the current location and rotation of the gripper.""" + return await self.backend.get_gripper_location(backend_params=backend_params) + + # -- holding state ----------------------------------------------------------- + + def _begin_holding(self, resource_width: float): + if self.holding: + name = self._picked_up.resource.name if self._picked_up else "" + raise RuntimeError(f"Already holding{' ' + name if name else ''}") + self._holding_resource_width = resource_width + + def _end_holding(self): + self._picked_up = None + self._holding_resource_width = None + + # -- coordinate computation ------------------------------------------------- + + def _pickup_location( + self, + resource: Resource, + offset: Coordinate, + pickup_distance_from_top: float, + ) -> Coordinate: + assert self._reference_resource is not None + center = resource.center().rotated(resource.get_absolute_rotation()) + if resource.is_in_subtree_of(self._reference_resource): + loc = resource.get_location_wrt(self._reference_resource, "l", "f", "t") + center + offset + else: + loc = center + offset + return Coordinate(loc.x, loc.y, loc.z - pickup_distance_from_top) + + def _destination_location( + self, + resource: Resource, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + resource_rotation_wrt_destination_wrt_local: float, + ) -> Coordinate: + assert self._reference_resource is not None + if isinstance(destination, ResourceStack): + assert destination.direction == "z" + return destination.get_location_wrt( + self._reference_resource + ) + destination.get_new_child_location( + resource.rotated(z=resource_rotation_wrt_destination_wrt_local) + ).rotated(destination.get_absolute_rotation()) + elif isinstance(destination, Coordinate): + return destination + elif isinstance(destination, ResourceHolder): + if destination.resource is not None and destination.resource is not resource: + raise RuntimeError("Destination already has a plate") + child_wrt_parent = destination.get_default_child_location( + resource.rotated(z=resource_rotation_wrt_destination_wrt_local) + ).rotated(destination.get_absolute_rotation()) + return destination.get_location_wrt(self._reference_resource) + child_wrt_parent + elif isinstance(destination, PlateAdapter): + if not isinstance(resource, Plate): + raise ValueError("Only plates can be moved to a PlateAdapter") + adjusted_plate_anchor = destination.compute_plate_location( + resource.rotated(z=resource_rotation_wrt_destination_wrt_local) + ).rotated(destination.get_absolute_rotation()) + return destination.get_location_wrt(self._reference_resource) + adjusted_plate_anchor + elif isinstance(destination, Plate) and isinstance(resource, Lid): + plate_location = destination.get_location_wrt(self._reference_resource) + child_wrt_parent = destination.get_lid_location( + resource.rotated(z=resource_rotation_wrt_destination_wrt_local) + ).rotated(destination.get_absolute_rotation()) + return plate_location + child_wrt_parent + else: + return destination.get_location_wrt(self._reference_resource) + + def _compute_end_effector_location( + self, + resource: Resource, + to_location: Coordinate, + offset: Coordinate, + pickup_distance_from_top: float, + rotation_applied_by_move: float, + ) -> Coordinate: + center = resource.center().rotated( + Rotation(z=resource.get_absolute_rotation().z + rotation_applied_by_move) + ) + loc = to_location + center + offset + return Coordinate( + loc.x, + loc.y, + loc.z + resource.get_absolute_size_z() - pickup_distance_from_top, + ) + + def _move_location( + self, + resource: Resource, + to: Coordinate, + offset: Coordinate, + pickup_distance_from_top: float, + ) -> Coordinate: + return to + resource.get_anchor("c", "c", "t") - Coordinate(z=pickup_distance_from_top) + offset + + def _resolve_pickup_distance( + self, resource: Resource, pickup_distance_from_top: Optional[float] + ) -> float: + if pickup_distance_from_top is not None: + return pickup_distance_from_top + if resource.preferred_pickup_location is not None: + logger.debug( + "Using preferred pickup location for resource %s as pickup_distance_from_top was " + "not specified.", + resource.name, + ) + return resource.get_size_z() - resource.preferred_pickup_location.z + logger.debug( + "No preferred pickup location for resource %s. Using default pickup distance of 5mm.", + resource.name, + ) + return 5.0 + + def _assign_after_drop( + self, + resource: Resource, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + ) -> None: + assert self._reference_resource is not None + resource.unassign() + if isinstance(destination, Coordinate): + destination -= self._reference_resource.location + self._reference_resource.assign_child_resource(resource, location=destination) + elif isinstance(destination, ResourceHolder): + destination.assign_child_resource(resource) + elif isinstance(destination, ResourceStack): + if destination.direction != "z": + raise ValueError("Only ResourceStacks with direction 'z' are currently supported") + destination.assign_child_resource(resource) + elif isinstance(destination, Tilter): + destination.assign_child_resource(resource, location=destination.child_location) + elif isinstance(destination, PlateAdapter): + if not isinstance(resource, Plate): + raise ValueError("Only plates can be moved to a PlateAdapter") + destination.assign_child_resource( + resource, location=destination.compute_plate_location(resource) + ) + elif isinstance(destination, Plate) and isinstance(resource, Lid): + destination.assign_child_resource(resource) + elif isinstance(destination, Trash): + pass + else: + destination.assign_child_resource( + resource, location=destination.get_location_wrt(self._reference_resource) + ) + + def _compute_drop( + self, + resource: Resource, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + offset: Coordinate, + pickup_distance_from_top: float, + rotation_applied_by_move: float = 0, + ) -> Tuple[Coordinate, float]: + resource_absolute_rotation_after_move = ( + resource.get_absolute_rotation().z + rotation_applied_by_move + ) + dest_rotation = ( + destination.get_absolute_rotation().z if not isinstance(destination, Coordinate) else 0 + ) + resource_rotation_wrt_destination = resource_absolute_rotation_after_move - dest_rotation + resource_rotation_wrt_destination_wrt_local = ( + resource_rotation_wrt_destination - resource.rotation.z + ) + + if isinstance(destination, ResourceStack): + if resource_rotation_wrt_destination % 180 != 0: + raise ValueError( + "Resource rotation wrt ResourceStack must be a multiple of 180 degrees, " + f"got {resource_rotation_wrt_destination} degrees" + ) + + to_location = self._destination_location( + resource, destination, resource_rotation_wrt_destination_wrt_local + ) + location = self._compute_end_effector_location( + resource, to_location, offset, pickup_distance_from_top, rotation_applied_by_move + ) + return location, resource_rotation_wrt_destination + + def _prepare_pickup( + self, + resource: Resource, + offset: Coordinate, + pickup_distance_from_top: Optional[float], + ) -> Tuple[Coordinate, float]: + pickup_distance_from_top = self._resolve_pickup_distance(resource, pickup_distance_from_top) + assert resource.get_absolute_rotation().x == 0 and resource.get_absolute_rotation().y == 0 + assert resource.get_absolute_rotation().z % 90 == 0 + location = self._pickup_location(resource, offset, pickup_distance_from_top) + return location, pickup_distance_from_top + + def _prepare_drop( + self, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + ) -> Resource: + if self._picked_up is None: + raise RuntimeError("No resource picked up") + if isinstance(destination, Resource): + destination.check_can_drop_resource_here(self._picked_up.resource) + return self._picked_up.resource + + def _finalize_drop( + self, + resource: Resource, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + resource_rotation_wrt_destination: float, + ) -> None: + self._end_holding() + self._state_updated() + resource.rotate(z=resource_rotation_wrt_destination - resource.rotation.z) + self._assign_after_drop(resource, destination) + + +class GripperArm(_BaseArm): + """A gripper arm without rotation capability. E.g. Hamilton core grippers.""" + + def __init__( + self, + backend: GripperArmBackend, + reference_resource: Resource, + grip_axis: Literal["x", "y"] = "x", + ): + super().__init__(backend=backend, reference_resource=reference_resource) + self.backend: GripperArmBackend = backend + self._grip_axis = grip_axis + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + return await self.backend.open_gripper( + gripper_width=gripper_width, backend_params=backend_params + ) + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + return await self.backend.close_gripper( + gripper_width=gripper_width, backend_params=backend_params + ) + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + return await self.backend.is_gripper_closed(backend_params=backend_params) + + def _resource_width(self, resource: Resource) -> float: + if self._grip_axis == "y": + return resource.get_absolute_size_y() + return resource.get_absolute_size_x() + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ): + self._begin_holding(resource_width) + await self.backend.pick_up_at_location( + location=location, resource_width=resource_width, backend_params=backend_params + ) + + async def pick_up_resource( + self, + resource: Resource, + offset: Coordinate = Coordinate.zero(), + pickup_distance_from_top: Optional[float] = None, + backend_params: Optional[BackendParams] = None, + ): + location, pickup_distance_from_top = self._prepare_pickup( + resource, offset, pickup_distance_from_top + ) + resource_width = self._resource_width(resource) + await self.pick_up_at_location(location, resource_width, backend_params) + self._picked_up = _PickedUpState( + resource=resource, + offset=offset, + pickup_distance_from_top=pickup_distance_from_top, + resource_width=resource_width, + ) + self._state_updated() + + async def drop_at_location( + self, + location: Coordinate, + backend_params: Optional[BackendParams] = None, + ): + if self._holding_resource_width is None: + raise RuntimeError("Not holding anything") + await self.backend.drop_at_location( + location=location, resource_width=self._holding_resource_width, backend_params=backend_params + ) + self._end_holding() + + async def drop_resource( + self, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + offset: Coordinate = Coordinate.zero(), + backend_params: Optional[BackendParams] = None, + ): + resource = self._prepare_drop(destination) + if self._picked_up is None: + raise RuntimeError("No resource picked up") + location, rotation = self._compute_drop( + resource=resource, + destination=destination, + offset=offset, + pickup_distance_from_top=self._picked_up.pickup_distance_from_top, + ) + await self.drop_at_location(location, backend_params) + self._finalize_drop(resource, destination, rotation) + + async def move_to_location( + self, location: Coordinate, backend_params: Optional[BackendParams] = None + ): + await self.backend.move_to_location(location=location, backend_params=backend_params) + + async def move_picked_up_resource( + self, + to: Coordinate, + offset: Coordinate = Coordinate.zero(), + backend_params: Optional[BackendParams] = None, + ): + if self._picked_up is None: + raise RuntimeError("No resource picked up") + location = self._move_location( + self._picked_up.resource, to, offset, self._picked_up.pickup_distance_from_top + ) + await self.backend.move_to_location(location=location, backend_params=backend_params) + + async def move_resource( + self, + resource: Resource, + to: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + intermediate_locations: Optional[List[Coordinate]] = None, + pickup_offset: Coordinate = Coordinate.zero(), + destination_offset: Coordinate = Coordinate.zero(), + pickup_distance_from_top: float = 0, + pickup_backend_params: Optional[BackendParams] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + await self.pick_up_resource( + resource=resource, + offset=pickup_offset, + pickup_distance_from_top=pickup_distance_from_top, + backend_params=pickup_backend_params, + ) + for loc in intermediate_locations or []: + await self.move_picked_up_resource(to=loc) + await self.drop_resource( + destination=to, offset=destination_offset, backend_params=drop_backend_params + ) diff --git a/pylabrobot/arms/arm_tests.py b/pylabrobot/arms/arm_tests.py new file mode 100644 index 00000000000..04b64c0f81f --- /dev/null +++ b/pylabrobot/arms/arm_tests.py @@ -0,0 +1,181 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.arms.arm import GripperArm +from pylabrobot.arms.backend import ( + GripperArmBackend, + OrientableGripperArmBackend, +) +from pylabrobot.arms.orientable_arm import OrientableArm +from pylabrobot.arms.standard import GripDirection +from pylabrobot.resources import Coordinate, Resource, ResourceHolder + + +def _assert_location(test, call, x, y, z, places=1): + """Assert the location kwarg of a mock call matches expected coordinates.""" + loc = call.kwargs["location"] + test.assertAlmostEqual(loc.x, x, places=places) + test.assertAlmostEqual(loc.y, y, places=places) + test.assertAlmostEqual(loc.z, z, places=places) + + +def _make_deck_with_sites(): + """Create a fictional deck with two sites and a plate. + + Deck: 1000x1000x0 at origin. + Site A at (100, 100, 50), site B at (100, 300, 50). + Plate: 120x80x10 assigned to site A. + """ + deck = Resource("deck", size_x=1000, size_y=1000, size_z=0) + + site_a = ResourceHolder("site_a", size_x=130, size_y=90, size_z=0) + deck.assign_child_resource(site_a, location=Coordinate(100, 100, 50)) + + site_b = ResourceHolder("site_b", size_x=130, size_y=90, size_z=0) + deck.assign_child_resource(site_b, location=Coordinate(100, 300, 50)) + + plate = Resource("plate", size_x=120, size_y=80, size_z=10) + site_a.assign_child_resource(plate, location=Coordinate(5, 5, 0)) + + return deck, site_a, site_b, plate + + +class TestArm(unittest.IsolatedAsyncioTestCase): + """Test Arm (ArmBackend, no rotation). E.g. Hamilton core grippers.""" + + async def asyncSetUp(self): + self.mock_backend = MagicMock(spec=GripperArmBackend) + for method_name in [ + "pick_up_at_location", + "drop_at_location", + "move_to_location", + "open_gripper", + "close_gripper", + "is_gripper_closed", + "halt", + "park", + ]: + setattr(self.mock_backend, method_name, AsyncMock()) + + self.deck, self.site_a, self.site_b, self.plate = _make_deck_with_sites() + self.arm = GripperArm(backend=self.mock_backend, reference_resource=self.deck) + + async def test_pick_up_resource(self): + # plate center: site_a(100,100,50) + child_loc(5,5,0) + center(60,40,10) = (165, 145, 60) + # pickup_distance_from_top=2 → z = 60 - 2 = 58 + await self.arm.pick_up_resource(self.plate, pickup_distance_from_top=2) + call = self.mock_backend.pick_up_at_location.call_args + _assert_location(self, call, 165, 145, 58) + # default grip_axis="x" → resource_width is X size = 120 + self.assertAlmostEqual(call.kwargs["resource_width"], 120) + + async def test_drop_resource(self): + await self.arm.pick_up_resource(self.plate, pickup_distance_from_top=2) + await self.arm.drop_resource(self.site_b) + call = self.mock_backend.drop_at_location.call_args + # site_b(100,300,50) + default_child_loc(0,0,0) + center(60,40,10) = (160, 340, 60) + # pickup_distance_from_top=2 → z = 60 - 2 = 58 + _assert_location(self, call, 160, 340, 58) + self.assertEqual(self.plate.parent.name, "site_b") + + async def test_pick_up_at_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.pick_up_at_location(location, resource_width=80.0) + self.mock_backend.pick_up_at_location.assert_called_once_with( + location=location, resource_width=80.0, backend_params=None + ) + + async def test_drop_at_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.pick_up_at_location(location, resource_width=80.0) + await self.arm.drop_at_location(location) + self.mock_backend.drop_at_location.assert_called_once_with( + location=location, resource_width=80.0, backend_params=None + ) + + async def test_move_to_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.move_to_location(location) + self.mock_backend.move_to_location.assert_called_once_with( + location=location, backend_params=None + ) + + async def test_open_gripper(self): + await self.arm.open_gripper(gripper_width=50.0) + self.mock_backend.open_gripper.assert_called_once_with(gripper_width=50.0, backend_params=None) + + async def test_halt(self): + await self.arm.halt() + self.mock_backend.halt.assert_called_once() + + async def test_park(self): + await self.arm.park() + self.mock_backend.park.assert_called_once() + + async def test_grip_axis_y(self): + """With grip_axis='y', resource_width should be the Y size.""" + arm_y = GripperArm(backend=self.mock_backend, reference_resource=self.deck, grip_axis="y") + await arm_y.pick_up_resource(self.plate, pickup_distance_from_top=2) + call = self.mock_backend.pick_up_at_location.call_args + # plate size_y=80 + self.assertAlmostEqual(call.kwargs["resource_width"], 80) + + +class TestOrientableArm(unittest.IsolatedAsyncioTestCase): + """Test OrientableArm coordinate computation with fictional resources.""" + + async def asyncSetUp(self): + self.mock_backend = MagicMock(spec=OrientableGripperArmBackend) + for method_name in [ + "pick_up_at_location", + "drop_at_location", + "move_to_location", + ]: + setattr(self.mock_backend, method_name, AsyncMock()) + + self.deck, self.site_a, self.site_b, self.plate = _make_deck_with_sites() + self.arm = OrientableArm(backend=self.mock_backend, reference_resource=self.deck) + + async def test_pick_up_front(self): + await self.arm.pick_up_resource( + self.plate, pickup_distance_from_top=2, direction=GripDirection.FRONT + ) + call = self.mock_backend.pick_up_at_location.call_args + _assert_location(self, call, 165, 145, 58) + self.assertAlmostEqual(call.kwargs["direction"], 0.0) + # FRONT → X width = 120 + self.assertAlmostEqual(call.kwargs["resource_width"], 120) + + async def test_pick_up_right(self): + await self.arm.pick_up_resource( + self.plate, pickup_distance_from_top=2, direction=GripDirection.RIGHT + ) + call = self.mock_backend.pick_up_at_location.call_args + self.assertAlmostEqual(call.kwargs["direction"], 90.0) + # RIGHT → Y width = 80 + self.assertAlmostEqual(call.kwargs["resource_width"], 80) + + async def test_drop_at_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.pick_up_at_location(location, resource_width=80.0, direction=0.0) + await self.arm.drop_at_location(location, direction=180.0) + self.mock_backend.drop_at_location.assert_called_once_with( + location=location, direction=180.0, resource_width=80.0, backend_params=None + ) + + async def test_move_to_location(self): + location = Coordinate(x=100, y=200, z=300) + await self.arm.move_to_location(location, direction=90.0) + self.mock_backend.move_to_location.assert_called_once_with( + location=location, direction=90.0, backend_params=None + ) + + async def test_move_plate(self): + """Pick from site_a, drop at site_b.""" + await self.arm.pick_up_resource( + self.plate, pickup_distance_from_top=2, direction=GripDirection.FRONT + ) + await self.arm.drop_resource(self.site_b, direction=GripDirection.FRONT) + drop_call = self.mock_backend.drop_at_location.call_args + _assert_location(self, drop_call, 160, 340, 58) + self.assertEqual(self.plate.parent.name, "site_b") diff --git a/pylabrobot/arms/backend.py b/pylabrobot/arms/backend.py new file mode 100644 index 00000000000..35e744de690 --- /dev/null +++ b/pylabrobot/arms/backend.py @@ -0,0 +1,212 @@ +from abc import ABCMeta, abstractmethod +from typing import Dict, List, Optional + +from pylabrobot.arms.standard import GripperLocation +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend +from pylabrobot.resources import Coordinate +from pylabrobot.resources.rotation import Rotation + +# ArmBackend: +# - pick_up_at_location +# - drop_at_location +# - move_to_location +# - get_gripper_location +# - is_holding_resource + +# CanGrip +# - open_gripper +# - close_gripper +# - is_gripper_closed + +# CanSuction +# - start_suction +# - stop_suction + +# CanFreedrive +# - start_freedrive_mode +# - stop_freedrive_mode + +# Joints +# - pick_up_at_joint_position +# - drop_at_joint_position +# - get_joint_position + + +class CanFreedrive(metaclass=ABCMeta): + """Mixin for arms that support freedrive (manual guidance) mode.""" + + @abstractmethod + async def start_freedrive_mode( + self, free_axes: List[int], backend_params: Optional[BackendParams] = None + ) -> None: + """Enter freedrive mode, allowing manual movement of the specified joints. + + Args: + free_axes: List of joint indices to free. Use [0] for all axes. + """ + + @abstractmethod + async def stop_freedrive_mode(self, backend_params: Optional[BackendParams] = None) -> None: + """Exit freedrive mode.""" + + +class HasJoints(metaclass=ABCMeta): + """Mixin for arms that can be controlled in joint space.""" + + @abstractmethod + async def pick_up_at_joint_position( + self, + position: Dict[int, float], + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up at the specified joint position.""" + + @abstractmethod + async def drop_at_joint_position( + self, + position: Dict[int, float], + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop at the specified joint position.""" + + @abstractmethod + async def move_to_joint_position( + self, position: Dict[int, float], backend_params: Optional[BackendParams] = None + ) -> None: + """Move the arm to the specified joint position.""" + + @abstractmethod + async def get_joint_position( + self, backend_params: Optional[BackendParams] = None + ) -> Dict[int, float]: + """Get the current position of the arm in joint space.""" + + +class CanGrip(metaclass=ABCMeta): + """Mixin for arms that have a gripper.""" + + @abstractmethod + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Open the gripper to the specified width.""" + + @abstractmethod + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Close the gripper to the specified width.""" + + @abstractmethod + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + """Check if the gripper is currently closed.""" + + +class _BaseArmBackend(CapabilityBackend, metaclass=ABCMeta): + @abstractmethod + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + """Stop any ongoing movement of the arm.""" + + @abstractmethod + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Park the arm to its default position.""" + + @abstractmethod + async def get_gripper_location( + self, backend_params: Optional[BackendParams] = None + ) -> GripperLocation: + """Get the current location and rotation of the gripper.""" + + +class GripperArmBackend(_BaseArmBackend, CanGrip, metaclass=ABCMeta): + """Backend for a simple arm (no rotation capability). E.g. Hamilton core grippers.""" + + @abstractmethod + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up at the specified location.""" + + @abstractmethod + async def drop_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop at the specified location.""" + + @abstractmethod + async def move_to_location( + self, location: Coordinate, backend_params: Optional[BackendParams] = None + ) -> None: + """Move the held object to the specified location.""" + + +class OrientableGripperArmBackend(_BaseArmBackend, CanGrip, metaclass=ABCMeta): + """Backend for an arm with rotation capability. E.g. Hamilton iSwap.""" + + @abstractmethod + async def pick_up_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up at the specified location with rotation.""" + + @abstractmethod + async def drop_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop at the specified location with rotation.""" + + @abstractmethod + async def move_to_location( + self, + location: Coordinate, + direction: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move the held object to the specified location with rotation.""" + + +class ArticulatedGripperArmBackend(_BaseArmBackend, CanGrip, metaclass=ABCMeta): + @abstractmethod + async def pick_up_at_location( + self, + location: Coordinate, + rotation: Rotation, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up at the specified location with rotation.""" + + @abstractmethod + async def drop_at_location( + self, + location: Coordinate, + rotation: Rotation, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop at the specified location with rotation.""" + + @abstractmethod + async def move_to_location( + self, + location: Coordinate, + rotation: Rotation, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move the held object to the specified location with rotation.""" diff --git a/pylabrobot/arms/orientable_arm.py b/pylabrobot/arms/orientable_arm.py new file mode 100644 index 00000000000..57b46e21609 --- /dev/null +++ b/pylabrobot/arms/orientable_arm.py @@ -0,0 +1,159 @@ +from typing import Optional, Union + +from pylabrobot.arms.arm import GripOrientation, _BaseArm, _PickedUpState +from pylabrobot.arms.backend import OrientableGripperArmBackend +from pylabrobot.arms.standard import GripDirection +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Coordinate, Resource, ResourceHolder, ResourceStack +from pylabrobot.resources.rotation import Rotation + +_GRIP_DIRECTION_TO_DEGREES = { + GripDirection.FRONT: 0.0, + GripDirection.RIGHT: 90.0, + GripDirection.BACK: 180.0, + GripDirection.LEFT: 270.0, +} + + +def _resolve_direction(direction: GripOrientation) -> float: + if isinstance(direction, GripDirection): + return _GRIP_DIRECTION_TO_DEGREES[direction] + return direction + + +class OrientableArm(_BaseArm): + """An arm with rotation capability. E.g. Hamilton iSWAP.""" + + def __init__(self, backend: OrientableGripperArmBackend, reference_resource: Resource): + super().__init__(backend=backend, reference_resource=reference_resource) + self.backend: OrientableGripperArmBackend = backend # type: ignore[assignment] + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + await self.backend.open_gripper(gripper_width=gripper_width, backend_params=backend_params) + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + await self.backend.close_gripper(gripper_width=gripper_width, backend_params=backend_params) + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + return await self.backend.is_gripper_closed(backend_params=backend_params) + + @staticmethod + def _resource_width_for_direction(resource: Resource, direction: float) -> float: + # TODO: resource rotation is not taken into account here. + if direction % 180 == 0: + return resource.get_absolute_size_x() + else: + return resource.get_absolute_size_y() + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + direction: GripOrientation = 0.0, + backend_params: Optional[BackendParams] = None, + ): + dir_degrees = _resolve_direction(direction) + self._begin_holding(resource_width) + await self.backend.pick_up_at_location( + location=location, + direction=dir_degrees, + resource_width=resource_width, + backend_params=backend_params, + ) + + async def pick_up_resource( + self, + resource: Resource, + offset: Coordinate = Coordinate.zero(), + pickup_distance_from_top: Optional[float] = None, + direction: GripOrientation = GripDirection.FRONT, + backend_params: Optional[BackendParams] = None, + ): + location, pickup_distance_from_top = self._prepare_pickup( + resource, offset, pickup_distance_from_top + ) + dir_degrees = _resolve_direction(direction) + resource_width = self._resource_width_for_direction(resource, dir_degrees) + # if gripper: + await self.pick_up_at_location(location, resource_width, dir_degrees, backend_params) + # if suction: + # TODO: + self._picked_up = _PickedUpState( + resource=resource, + offset=offset, + pickup_distance_from_top=pickup_distance_from_top, + resource_width=resource_width, + rotation=Rotation(z=dir_degrees), + ) + self._state_updated() + + async def drop_at_location( + self, + location: Coordinate, + direction: GripOrientation, + backend_params: Optional[BackendParams] = None, + ): + if self._holding_resource_width is None: + raise RuntimeError("Not holding anything") + await self.backend.drop_at_location( + location=location, + direction=_resolve_direction(direction), + resource_width=self._holding_resource_width, + backend_params=backend_params, + ) + self._end_holding() + + async def drop_resource( + self, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + offset: Coordinate = Coordinate.zero(), + direction: GripOrientation = GripDirection.FRONT, + backend_params: Optional[BackendParams] = None, + ): + resource = self._prepare_drop(destination) + if self._picked_up is None: + raise RuntimeError("No resource picked up") + drop_dir = _resolve_direction(direction) + rotation_applied_by_move = (drop_dir - self._picked_up.rotation.z) % 360 + location, rotation = self._compute_drop( + resource=resource, + destination=destination, + offset=offset, + pickup_distance_from_top=self._picked_up.pickup_distance_from_top, + rotation_applied_by_move=rotation_applied_by_move, + ) + await self.drop_at_location(location, drop_dir, backend_params) + self._finalize_drop(resource, destination, rotation) + + async def move_to_location( + self, + location: Coordinate, + direction: GripOrientation = 0.0, + backend_params: Optional[BackendParams] = None, + ): + await self.backend.move_to_location( + location=location, + direction=_resolve_direction(direction), + backend_params=backend_params, + ) + + async def move_picked_up_resource( + self, + to: Coordinate, + direction: GripOrientation, + offset: Coordinate = Coordinate.zero(), + backend_params: Optional[BackendParams] = None, + ): + if self._picked_up is None: + raise RuntimeError("No resource picked up") + dir_degrees = _resolve_direction(direction) + location = self._move_location( + self._picked_up.resource, to, offset, self._picked_up.pickup_distance_from_top + ) + await self.backend.move_to_location( + location=location, direction=dir_degrees, backend_params=backend_params + ) diff --git a/pylabrobot/arms/standard.py b/pylabrobot/arms/standard.py new file mode 100644 index 00000000000..6b6a416b513 --- /dev/null +++ b/pylabrobot/arms/standard.py @@ -0,0 +1,41 @@ +import enum +from dataclasses import dataclass + +from pylabrobot.resources import Coordinate, Rotation + + +@dataclass +class GripperLocation: + """Location and rotation of the gripper. Subclass for robot-specific fields.""" + + location: Coordinate + rotation: Rotation + + +class GripDirection(enum.Enum): + FRONT = enum.auto() + BACK = enum.auto() + LEFT = enum.auto() + RIGHT = enum.auto() + + +@dataclass(frozen=True) +class ResourcePickup: + location: Coordinate # center of end effector when gripping the resource + rotation: Rotation # rotation of end effector when gripping the resource + resource_width: float + + +@dataclass(frozen=True) +class ResourceMove: + """Moving a resource that was already picked up.""" + + location: Coordinate # center of end effector when moving the resource + rotation: Rotation # rotation of end effector when moving the resource + + +@dataclass(frozen=True) +class ResourceDrop: + location: Coordinate # center of end effector when dropping the resource + rotation: Rotation # rotation of end effector when dropping the resource + resource_width: float diff --git a/pylabrobot/brooks/__init__.py b/pylabrobot/brooks/__init__.py new file mode 100644 index 00000000000..37b32a83502 --- /dev/null +++ b/pylabrobot/brooks/__init__.py @@ -0,0 +1,6 @@ +from .precise_flex import ( + PreciseFlex400, + PreciseFlex3400Backend, + PreciseFlexArmBackend, + PreciseFlexDriver, +) diff --git a/pylabrobot/brooks/error_codes.py b/pylabrobot/brooks/error_codes.py new file mode 100644 index 00000000000..02515cdbefd --- /dev/null +++ b/pylabrobot/brooks/error_codes.py @@ -0,0 +1,1809 @@ +ERROR_CODES = { + 0: {"text": "Success", "description": "Operation completed successfully without an error."}, + 1: { + "text": "Warning", + "description": "Operation completed without an error, but an anomaly occurred that should be brought to the attention of the system operator.", + }, + -200: { + "text": "No memory available", + "description": "There is not sufficient memory to perform the requested operation. Large amounts of memory may be used by the following: loaded GPL procedures, arrays, strings, and the Data Logger. Check if any of these items are unusually large.", + }, + -201: { + "text": "System internal consistency error", + "description": "This error indicates that a condition has been detected that should not be possible if the controller and its system software are operating properly. Most likely, this indicates that a software bug has been encountered. Please report this message along with the process necessary to generate this problem to Precise.", + }, + -202: { + "text": "Invalid argument", + "description": "The value of an argument used in a GPL instruction, built-in method, or console command is not allowed.", + }, + -203: { + "text": "FIFO overflowed", + "description": "An overflow has occurred in a system data structure. Most likely, this indicates that a software bug has been encountered. Please report this message along with the process necessary to generate this problem to Precise.", + }, + -204: { + "text": "Not implemented", + "description": "You have attempted to use a feature of GPL or the Precise Controller that is planned but not implemented.", + }, + -205: { + "text": "Missing argument", + "description": "A required argument for a GPL instruction, built-in method, or console command was not supplied.", + }, + -206: { + "text": "Invalid auto execute mode", + "description": 'The "Auto execution mode" specifies the major motion control function that the controller should perform. For example, the execution of the DIOMotion Blocks and GPL projects are examples of two "Auto execution modes". This error indicates that an invalid mode has been specified. Most likely, the value of "Automatic execution mode" (DataID 200) has been set incorrectly.', + }, + -207: { + "text": "Too many", + "description": "The current operation has attempted to create more items of an internal data structure than is allowed. Most likely, this indicates that a software bug has been encountered. Please report this message along with the process necessary to generate this problem to Precise.", + }, + -208: { + "text": "Protection error", + "description": "You have attempted to read a hidden value, access a secured data area without proper authorization, or use the Data Logger on data that cannot be logged. This error is often generated by GPL application programs that attempt to write to Parameter Database values that cannot be modified when motor power is enabled. In these cases, disable motor power and retry the operation.", + }, + -209: { + "text": "Read only", + "description": "This error message indicates that you have attempted to alter a value that can only be read. For example, this error is generated if you attempt to change the value of an analog input.", + }, + -210: { + "text": "Operating system error code", + "description": "This error message indicates that the underlying real-time operating system (the RTOS) or one of its communications drivers has signaled an error that does not correspond to a standard GPL error. The numeric code contains the RTOS error number.", + }, + -211: { + "text": "Option not enabled", + "description": """This error is generated if you attempt to execute or enable a software or hardware option that is either not available in your system or has not been enabled. Typically, this indicates that your system software does not support the desired feature or a parameter has not be properly set. This is different from missing a required license bit, which is indicated by the "License not installed" error message. Normally, this error includes one of the following codes that further defines the problem. + + 1: Split axis capability is not supported by kinematic module. + 2: External trajectory interface is not support by system software. + 3: Ethernet is not supported by the system software. + 4: TCP network protocol is not supported by system software, so Telnet and GDE may not be used. + 5: UDP network protocol is not supported by system software. + 6: Vision interface is not supported by system software. + 7: Motor linearity compensation is either not supported by the kinematic module or is not enabled." + """, + }, + -212: { + "text": "License not installed", + "description": """A required software license is not installed on your controller. The name of the missing license is shown after this error message. The license names include: + + (1) Guidance Programming Language + (2) Motion control + (3) Conveyor tracking + (4) Advanced kinematics + (5) Complex kinematics + (6) Advanced controls + (7) Enhanced encoder + (8) Encoder latching + (9) RIO motion control + (10) Software application + (11) G&M code support + (12) Motion no kinematics support + (13) External trajectory + (14) XY compliance + (15) Z height detection + (16) GuidanceMotion application + (17) EtherCAT Master + +Older GPL systems may display the license number shown above in ( ) rather than the name. The currently installed licenses can be viewed via the web interface by selecting Utilities>Controller Options or by accessing DataID 112, "Software license option bits". To obtain a required license, contact your system administrator or Brooks.""", + }, + -213: { + "text": "Invalid password", + "description": "An invalid password was entered for a protected operation or facility. Please re-enter the correct password or ask your system administrator for the correct password.", + }, + -214: { + "text": "Cancelled", + "description": "An operation was initiated that was subsequently cancelled. No further action is required.", + }, + -215: { + "text": "No system clock interrupts", + "description": "Many of the major subsystems of the controller (e.g. the servo code, the trajectory generator, the GPL projects) are serviced by different threads of software that execute independently. This thread execution is governed in part by the ticking of a master system clock. When the system restarts, it automatically tests the system clock to ensure that it is ticking at the correct rate. These errors signify that the system clock is not operating properly. Most likely, this indicates a hardware problem with the main processor board, the MIDS.", + }, + -216: { + "text": "System clock interrupts too slow", + "description": "Many of the major subsystems of the controller (e.g. the servo code, the trajectory generator, the GPL projects) are serviced by different threads of software that execute independently. This thread execution is governed in part by the ticking of a master system clock. When the system restarts, it automatically tests the system clock to ensure that it is ticking at the correct rate. These errors signify that the system clock is not operating properly. Most likely, this indicates a hardware problem with the main processor board, the MIDS.", + }, + -217: { + "text": "System clock interrupts too fast", + "description": "Many of the major subsystems of the controller (e.g. the servo code, the trajectory generator, the GPL projects) are serviced by different threads of software that execute independently. This thread execution is governed in part by the ticking of a master system clock. When the system restarts, it automatically tests the system clock to ensure that it is ticking at the correct rate. These errors signify that the system clock is not operating properly. Most likely, this indicates a hardware problem with the main processor board, the MIDS.", + }, + -218: { + "text": "Invalid task configuration", + "description": "An invalid parameter has been entered associated with one of the major tasks of the system. For example, the update period of the trajectory generator must be specified as a number that is a power of two.", + }, + -219: { + "text": "Incompatible FPGA version", + "description": 'The firmware in the FPGA is not compatible with the hardware or software configuration options in this controller. The FPGA ("Field Programmable Gate Array") controls the robot power sequencing, encoder interfaces, motor current loop and a host of other critical functions. There are multiple versions of FPGA firmware that support various motors, encoders, and other options. Please obtain a compatible version of the FPGA firmware data or modify your configuration parameters. FPGA firmware may be loaded by following the instructions in the FAQ section of the PreciseFlex™ PreciseFlex Library.', + }, + -220: { + "text": "Not configured", + "description": "You have attempted to access some aspect of the system that has not been configured. For example, you have attempted to set a parameter for an axis that was not defined in the configuration database.", + }, + -221: { + "text": "Invalid robot type", + "description": 'A kinematic robot module does not exist for the specified robot type. This typically indicates that a value specified in the "Robot types" (DataID 116) is incorrect. Please review this list of values and look at the information on the available robot kinematic modules.', + }, + -222: { + "text": "Remote software incompatible", + "description": "In a Servo Network system, the indicated slave controller contains software that is not compatible with the master controller. Update the slave controller's software to a compatible version. Normally the master and all slaves should run the identical GPL software version.", + }, + -223: {"text": "Incompatible system", "description": ""}, + -224: {"text": "FPGA load failed", "description": ""}, + -300: { + "text": "Invalid device", + "description": "The device specified for a file or I/O operation is of the wrong type for that operation. Check the spelling of the device name and verify what types of devices are appropriate for the operation you are attempting.", + }, + -301: { + "text": "Undefined device", + "description": "The device specified for a file or I/O operation is not recognized. Check the spelling of the device name. Verify that the device (e.g. remote I/O board) is installed in your system.", + }, + -302: { + "text": "Invalid device unit", + "description": "The unit for a device specified for a file or I/O operation is not valid for that operation. Verify what device's unit is appropriate for the operation you are attempting.", + }, + -303: { + "text": "Undefined device unit", + "description": "The unit for a device specified for a file or I/O operation is not recognized. Verify that the unit (e.g. serial port) is present in your system.", + }, + -304: { + "text": "Device already in use", + "description": "An attempt to open or attach a device has failed because the device is already in use. For example, an attempt has been made to open the /dev/com2 serial port from a GPL program while the hardware MCP that uses the same port is active. Check to make sure that the device you are accessing is available for use.", + }, + -305: {"text": "Logical device already in use", "description": ""}, + -306: {"text": "Duplicate logical device", "description": ""}, + -307: {"text": "Duplicate physical device", "description": ""}, + -308: { + "text": "Too many devices", + "description": "This error indicates that an internal software configuration bug has been encountered. Please report this message along with the process that generated this problem to Precise.", + }, + -309: { + "text": "No physical device mapped", + "description": "This error indicates that an internal software configuration bug has been encountered. Please report this message along with the process that generated this problem to Precise.", + }, + -310: { + "text": "Timeout waiting for device", + "description": "A device has failed to respond within the expected time period. This message may indicate a real error or the timeout may be expected if the system is testing for the presence of a device. Verify that the device is present. Increase the timeout value for the I/O operation.", + }, + -311: { + "text": "Date/time not set", + "description": "An attempt to read the system date or time has failed because the internal clock has not been initialized. Verify that your system contains a real-time clock. If so, this error may indicate a hardware failure such as a dead clock battery.", + }, + -312: { + "text": "Invalid date/time specification", + "description": "An attempt to set the system date or time has failed because the specification does not represent a valid date or time. Enter a valid specification.", + }, + -313: {"text": "CANOpen driver initialization failure", "description": ""}, + -314: { + "text": "Device not found", + "description": "An I/O device is not responding. This message may indicate a real error or the error may be expected if the system is testing for the presence of a device.", + }, + -315: { + "text": "NVRAM not responding", + "description": "A device connected to the I2C bus is not responding. This device may be the system NVRAM or it may be a different device. This message may indicate a real error or it may be expected if the system is testing for the presence of a device.", + }, + -316: { + "text": "NVRAM invalid response", + "description": "A device connected to the I2C bus is not communicating properly. This message indicates a hardware problem. Please report this message along with the process that generated this problem to Precise.", + }, + -317: { + "text": "Configuration area invalid", + "description": "The data in the system configuration area is not valid. The configuration data may have been corrupted. This error should not be seen on a control board that has been initialized. Please report this message along with the process that generated this problem to technical support.", + }, + -318: { + "text": "Real-time-clock disabled", + "description": "The real-time clock is disabled internally. This error should never be seen and indicates a hardware problem. Please report this message to Precise.", + }, + -319: { + "text": "Real-time-clock battery failed", + "description": "The real-time clock battery has failed. This error indicates a hardware problem. Please report this message to Precise.", + }, + -320: { + "text": "Device not ready", + "description": "An attempt was made to access a device that is busy. Depending on the device, it may be attached to a different thread, or it may have not been properly initialized. Try the operation again. Make sure the procedure used to access the device is correct.", + }, + -321: { + "text": "Invalid device command", + "description": "A device has rejected a command because it was not recognized. Check to make sure you are issuing the proper command for the device.", + }, + -322: { + "text": "i2c device failure", + "description": 'Robot power has been turned off because an unrecoverable error has occurred for an I2C device. I2C is used for a number of purposes including to communicate with the remote Z-axis digital I/O for the PrecisePlace 1300/1400 robots. This failure may be due to excessive electrical noise in your system. Check that your motor wiring and cable routing follows Precise guidelines. If your system is not a PP1300/1400, disable this error by setting parameter "Disable i2c errors" (DataID 920) to 1.', + }, + -323: { + "text": "Device full", + "description": "You have attempted to write data to a device that is full. No more data can be written until some of the existing data or files are deleted.", + }, + -324: { + "text": "MCP not recognized", + "description": "The hardware Manual Control Pendant connected to the Front Panel connector is not recognized as a Precise MCP. Consequently, the system cannot communicate with the device and the MCP driver is terminated. Please obtain an authorized MCP or contact Precise to repair your existing MCP.", + }, + -325: { + "text": "SIO device failure", + "description": 'Robot power has been turned off or has been inhibited from turning on because an SIO (RS-485) device has failed to respond to polling requests as expected. Verify that all required SIO devices are connected properly. Verify that only existing SIO devices are enabled in the "SIO mode flags" (DataID 571).', + }, + -326: { + "text": "Device not enabled", + "description": "An I/O operation has failed for a device because it is not enabled. The method for enabling a device depends on the particular device. Verify that any enable flags for the device are set in the configuration database.", + }, + -327: { + "text": "Invalid device configuration", + "description": "Some device configuration parameters are invalid. Check the values of the DataID reporting this error and verify that they are correct.", + }, + -328: { + "text": "PMIC not responding", + "description": "The power management controller is not responding as expected during system startup. This error should never be seen. Please report this message along with the process that generated this problem to technical support.", + }, + -700: { + "text": "Thread execution aborted", + "description": "A GPL user thread has been stopped by request (for example from a Stop command) or a robot error occurred while the robot was attached.", + }, + -702: { + "text": "Undefined thread", + "description": "The thread name specified in a Thread Class method or console command is not known to GPL. Verify that the thread name is correct. Verify that the thread has not been stopped and the name removed from the list of threads.", + }, + -704: { + "text": "Missing quote mark", + "description": 'A double quote character (") has not been found where expected at the end of a string specification or another syntax error in the statement prevented the compiler from finding the quote mark. Fix the statement syntax and add a quote mark if needed.', + }, + -705: { + "text": "Value too small", + "description": "A parameter database value, property value, or method parameter value is smaller than allowed. Check the relevant documentation and change the value to be within the proper range.", + }, + -706: { + "text": "Value too large", + "description": "A parameter database value, property value, or method parameter value is larger than allowed. Check the relevant documentation and change the value to be within the proper range.", + }, + -707: { + "text": "Value out-of-range", + "description": "A parameter database value, property value, or method parameter value is outside the range of allowed values. Check the relevant documentation and change the value to be within the proper range.", + }, + -708: { + "text": "Value infinite or NAN", + "description": 'A numeric value has been encountered that is too large for its new data type, or a floating point value has been encountered that is marked as "Not A Number" (NAN). This error can occur when converting from a larger numeric data type to a smaller one, for example Integer to Byte, or when performing a computation that results in a very large or infinite result, for example dividing by zero, or when computing the square root of a negative number. Check your procedure to eliminate these situations, or add checks to detect and handle them.', + }, + -709: { + "text": "Division by 0", + "description": "Indicates a division by zero was attempted. In matrix (Location) operations, this error can occur if the Z-axis orientation vector of a Cartesian Location has a zero length and the Location is being re-normalized. This can be caused by severe round-off error and can be corrected by normalizing the Location value more often.", + }, + -710: { + "text": "Arithmetic overflow", + "description": "This error indicates that a mathematical operation resulted in a number that is too large to represent. Since the majority of the internal computational operations are performed in double precision arithmetic, this typically indicates a result larger than 10^308 and normally indicates a programming error.", + }, + -711: { + "text": "Singular matrix", + "description": "A matrix operation is being performed and the determinate of the matrix is zero, i.e. the matrix is singular. This is typically detected when the system attempts to invert a matrix. For example, the conversion from joint angles to motor encoder values is represented as a matrix if the axes are mechanical coupled. When the system attempts to automatically compute the inverse conversion factors to go from motor encoder values to joint angles, a matrix inversion must be performed. This error indicates that a meaningful inverse relationship does not exist.", + }, + -712: { + "text": "Invalid syntax", + "description": "If encountered during program compilation, this message indicates that the GPL parser does not understand the current instruction. Either the instruction itself has an error, or it is being used in an unexpected situation. If encountered during execution, the arguments to a command, instruction, or method do not follow the expected format.", + }, + -713: { + "text": "Symbol too long", + "description": "An object or variable name exceeds the system's limit of 128 characters.", + }, + -714: { + "text": "Unknown command", + "description": "The command keyword for a console command is not recognized. Check to make sure you have entered the command correctly. Check that the command is supported by your version of GPL.", + }, + -715: { + "text": "Invalid procedure step", + "description": "An attempt has been made to execute a procedure step that is not executable. For example, during debugging, you have attempted to specify a comment line as the next step to execute.", + }, + -716: { + "text": "Ambiguous abbreviation", + "description": "A command keyword or parameter keyword has been entered that is an abbreviation for more than one command or parameter. Reenter the command specifying a longer abbreviation that matches only one keyword.", + }, + -717: { + "text": "Invalid number format", + "description": 'A numeric constant has been entered that does not match the expected format. For example, a floating point value has been entered with an empty exponent: "2.0E".', + }, + -718: { + "text": "Missing parentheses", + "description": 'After a left parenthesis "(" was encountered, the matching right parenthesis ")" was not found where expected. This error is sometimes reported when a syntax error occurs in the argument list for a procedure call, even if the right parenthesis is present. Add the missing parenthesis or fix any syntax errors.', + }, + -719: { + "text": "Illegal use of keyword", + "description": "A GPL keyword has been encountered in a place where it is invalid. For example, a keyword occurs in a declaration where it is not allowed, or an attempt was made to create a variable with the same name as a GPL keyword. Remove the keyword or rename the variable.", + }, + -720: { + "text": "Unexpected character in expression", + "description": "An unexpected character was encountered while evaluating a numeric or string expression. For example an operator was found in an unexpected place. Correct the expression syntax.", + }, + -721: {"text": "Conflict with reserved keyword", "description": ""}, + -722: { + "text": "Unexpected text at end of line", + "description": "Unexpected characters were found at the end of a statement. There may have been a previous syntax error that confused the compiler, or a missing comment character. Correct the line.", + }, + -723: { + "text": "Invalid statement label", + "description": "A statement label has been placed where it is not allowed, for example outside a procedure, or a Goto statement refers to a statement label that was not defined. Move or define the label.", + }, + -724: {"text": "Invalid End keyword", "description": ""}, + -725: { + "text": "Unknown data type", + "description": "During XML processing, a node with an unknown data type has been encountered. Correct the XML document.", + }, + -726: { + "text": "Data type required", + "description": 'A variable or procedure declaration is missing the "As" clause that specifies the data type. Add the appropriate clause and data type.', + }, + -727: { + "text": "Cannot redefine symbol", + "description": "An attempt has been made to define a symbol that already exists in the same context. This symbol may be the name of a project, module, class, procedure or variable. Change the name or scope of the symbol.", + }, + -728: {"text": "Procedure too long", "description": ""}, + -729: { + "text": "Undefined symbol", + "description": "A symbol has been encountered that is not declared within the current context. This symbol may be the name of a project, module, class, procedure or variable. Declare the symbol.", + }, + -730: { + "text": "Invalid symbol type", + "description": "A known symbol has been encountered in a statement but its type is not valid where it is being used. For example a Sub procedure name is used in an expression as if it were a function. Correct the statement.", + }, + -731: { + "text": "Unmatched parentheses", + "description": 'A right parenthesis ")" was encountered without being preceded by a left parenthesis "(". Add a "(" where appropriate, or remove the extra ")".', + }, + -732: { + "text": "Invalid procedure end", + "description": "When compiling a procedure, a top-level statement has been found other than the expected matching procedure end statement. Verify that the correct matching end statement is present.", + }, + -733: { + "text": "Not a top-level statement", + "description": "A statement has been encountered outside of a Module or Class definition block. Move the statement inside the block.", + }, + -734: { + "text": "Object not bound to class", + "description": 'A variable had been declared to be of type "Object" which is not supported by GPL. Correct the declaration.', + }, + -735: { + "text": "Too many nested blocks", + "description": "This typically indicates that a individual GPL procedure contains too many control structures, e.g. If..Then..Else or For loops, that are embedded within each other. The system currently has a limit of 100 nested control structures. To correct this problem, the procedure must be rewritten to reduce the nesting depth.", + }, + -736: { + "text": "Duplicate statement label", + "description": "An identical duplicate statement label has been encountered when compiling either a GPL program or a command script. Labels must be unique.", + }, + -737: { + "text": "Too many errors, compile cancelled", + "description": "The compiler has encountered more than 8 errors and is stopping the compile operation. Fix the errors already listed and compile again.", + }, + -738: { + "text": "Invalid data type", + "description": "A data type keyword has been encountered where it is not allowed. For example, a variable has been declared as type Function.", + }, + -739: { + "text": "Cannot change built-in symbol", + "description": "An attempt has been made to add to a built-in class or module. These built-in classes cannot be changed.", + }, + -740: {"text": "Empty procedure", "description": ""}, + -741: { + "text": "Argument mismatch", + "description": "The arguments in a statement, console command or procedure call do not match the parameters for that statement, console command or procedure call. Change the arguments so that they match the required parameters.", + }, + -742: { + "text": "Compilation errors", + "description": "A compilation has failed because of detected errors. The specific errors are listed during the compilation operation. Or, an attempt has been made to start a project that contains compilation errors. Fix the errors and recompile.", + }, + -743: { + "text": "Invalid project file", + "description": "The Project.gpr file in your project folder is not valid. This file is normally automatically generated and managed by GDE. If you edited this file by hand, double-check that the format is valid. Otherwise use GDE to rebuild the project or restore your project from a backup.", + }, + -744: { + "text": "Invalid start procedure", + "description": 'The "start" procedure specified for your project or by a Thread.Start method could not be found or is of the wrong type. Start procedures must be Public and of type Sub or Function. Correct the start procedure specification.', + }, + -745: { + "text": "Project already exists", + "description": "An attempt was made to load a project with the same name as a project currently loaded. Change the name of the second project, or unload the first project.", + }, + -746: { + "text": "Interlocked for read", + "description": "An attempt was made to change a system resource that is currently in use. Wait a short time and try again in case the lock was temporary. Otherwise, determine the thread that is using the resource and stop it. Then, try accessing the resource again.", + }, + -747: { + "text": "Interlocked for write", + "description": "An attempt was made to change a system resource that is actively being changed. For example two threads might be attempting to delete the same project simultaneously. Wait a short time and try again. Write locks are normally temporary", + }, + -748: { + "text": "No matching control structure", + "description": "A GPL procedure is missing statements that are necessary to properly terminate one or more control structures. For example, a For statement might be missing its matching Next statement or an If instruction might not be properly terminated by an End If statement or a Case statement may not immediately follow a Select statement.", + }, + -749: { + "text": "Thread already exists", + "description": "An attempt was made to create a thread with the same name as one that already exists. Unload the first thread, or rename the second thread.", + }, + -750: { + "text": "Invalid when thread active", + "description": "An attempt was made to perform an operation that cannot be executed while a thread is active. For example, you may have attempted to start a thread that is already active or you may have attempted to delete a project with an active thread.", + }, + -751: { + "text": "Timeout starting thread", + "description": "A thread has not started or restarted within 1 second of being requested to execute. This error often occurs when restarting a thread that is still winding down because it is taking longer than expected to perform its cleanup procedures. Stop or pause the thread and repeat the operation.", + }, + -752: { + "text": "Timeout stopping thread", + "description": "A thread has not stopped within 3 seconds of when a request to stop it occurred. The thread may be waiting for some operation to complete before it can stop. For example, it may be waiting for a robot motion or I/O operation to complete. This is not a critical error and the thread will stop when it completes whatever it is doing. This error is commonly seen if a thread stop request occurs after a thread has started a long robot motion. To stop a thread quickly in this case, issue a soft E-Stop just prior to requesting the thread to stop.", + }, + -753: { + "text": "Project not compiled", + "description": "An attempt was made to access a project that is not compiled. The project may not exist, or it may be loaded but not compiled. Load the project if not loaded and then compile it.", + }, + -754: { + "text": "Thread execution complete", + "description": "An attempt was made to continue execution of a thread that has run to completion. You must restart the thread with a Start command or Thread Start method.", + }, + -755: { + "text": "Thread stack too small", + "description": "An attempt was made to allocate more data on a thread stack than the stack is able to accommodate. You may have more nested procedure calls than expected or you may have allocated too many procedure-local variables. Verify that you do not have a program recursion bug. Check the stack usage with the Show Stack command. If required, specify a larger thread stack size with the Start command or the Thread Class constructor.", + }, + -756: { + "text": "Member not shared", + "description": "You have attempted to associate a Delegate object with a non-shared class procedure, but you have not provided an object reference in the Delegate New clause. Change your New clause to provide an object reference, or change your Delegate to refer to a shared procedure.", + }, + -757: { + "text": "Object not instantiated", + "description": 'An object is being assigned to on the left-hand side of an equal sign or is being referenced in an expression and the object value block has not been allocated. To correct this problem, use the "New" qualifier in the DIM statement that declared the object to allocate its value block.', + }, + -758: { + "text": "No Get Property defined", + "description": "An attempt has been made to read the value of a write-only property (that does not support Get). This can occur by attempting to assign a value to a write-only property with an assignment operator such as +=. Eliminate the read of the property.", + }, + -759: { + "text": "Undefined value", + "description": "An attempt was made to read a variable or procedure argument that does not have any value assigned. Be sure to assign a value to a variable before referring to it.", + }, + -760: { + "text": "Invalid assignment", + "description": "An attempt was made to assign a value to a variable with an incompatible data type. For example you cannot assign an object of one class to a variable for another class.", + }, + -761: { + "text": "Cannot have list of variables", + "description": "A declaration with an initializer may define only one variable. A list of variables is not allowed in this situation. Break your declaration into multiple statements.", + }, + -762: { + "text": "Location not a Cartesian type", + "description": 'A property or a method of a Location object requires a Cartesian type value, but the Location is an Angles type instead. For example, it is invalid to reference the "X" property of a Location defined as an Angles type.', + }, + -763: { + "text": "Location not an angles type", + "description": 'A property or a method of a Location object requires an Angles type value, but the Location is a Cartesian type instead. For example, it is invalid to reference the "Angle" property of a Location defined as a Cartesian type.', + }, + -764: { + "text": "Invalid procedure overload", + "description": "An attempt was made to declare a procedure with the same name as an existing procedure, and with arguments that match too closely so that it cannot be considered an overload. Change the arguments so that the two procedures can be distinguished by the compiler.", + }, + -765: { + "text": "Array index required", + "description": "An array reference was found that does not have the correct number of index arguments specified for the number of array dimensions. Specify the correct number.", + }, + -766: { + "text": "Array index mismatch", + "description": "An array argument does not have the same number of dimensions as an corresponding array parameter. Change the arrays so that the dimensions match.", + }, + -767: { + "text": "Invalid array index", + "description": "An array index value is negative or greater than the maximum permitted by its declaration. Or, an array argument was specified that does not contain sufficient elements for the matching array parameter.", + }, + -768: { + "text": "Unsupported array access", + "description": "An attempt was made to access an array in a way that is not supported. This error should never occur in GPL 3.1 or later.", + }, + -769: { + "text": "Reference frame wrong type", + "description": "A property or a method of a RefFrame object requires a particular type of reference frame value and the type is incorrectly set. For example, the PalletIndex property is only valid when the RefFrame Type is set to pallet.", + }, + -770: { + "text": "Reference frame undefined data", + "description": "When the position of a reference frame is evaluated either directly or as part of the absolute value of a Location that is relative to the reference frame or when a property of a reference frame is being accessed, this error is generated if the Loc of the reference frame is not defined. To correct this problem, assign a defined Cartesian Location value to the refframe.Loc property.", + }, + -771: { + "text": "Stack frame does not exist", + "description": 'A command that accepts a stack frame number has specified a stack frame that does not exist. Use the "show stack" command with no frame argument to display all frames and determine the maximum valid frame number.', + }, + -772: { + "text": "Ambiguous Public reference", + "description": "References to top-level public variables do not need to be qualified by their containing module or class provided that they are unique. However if the same public variable appears in more than one module or class, you must precede its name with the name of its module or class.", + }, + -773: { + "text": "Missing module end", + "description": 'An "End Module" or "End Class" statement was not found where it was expected. Add the appropriate statement.', + }, + -774: { + "text": "Too many breakpoints", + "description": 'More than 32 breakpoints have been set in GPL procedures. Remove some of the existing breakpoints or issue "Clear All Breakpoints" and set fewer breakpoints.', + }, + -775: { + "text": "Duplicate breakpoint", + "description": 'A breakpoint was set on a line that already contains a breakpoint. If GDE does not show a breakpoint on this line, GDE could be out-of-sync with GPL. Try disconnecting and reconnecting GDE. Try issuing "Clear All Breakpoints" and set your breakpoint again.', + }, + -776: { + "text": "No instruction at this line", + "description": 'A breakpoint was set in a procedure that does not exist or on a line that contains an instruction that does not allow breakpoints. In the second case, GPL tries to set a breakpoint on the next valid instruction, but this error will be generated if there is no valid instruction before the end of the procedure.\n\nThe error can occur if you edit your program and re-compile after adding/removing lines while having breakpoints set. It could also occur if GDE is out-of-sync with GPL. Try disconnecting and reconnecting GDE. Try issuing "Clear All Breakpoints" and set your breakpoints again.', + }, + -778: { + "text": "Objects not allowed for class", + "description": "An attempt was made to use the New clause to allocate an object for a built-in class that does not allow objects.", + }, + -779: { + "text": "Thread paused in eval", + "description": 'A console command such as "Show Variable" has invoked a procedure that has paused due to an error or breakpoint. The command cannot continue. Normally this error is not seen.', + }, + -780: { + "text": "Unsupported procedure reference", + "description": "A console command or a Const statement has attempted to call a user-defined function or property. This type of access is not supported.", + }, + -781: { + "text": "Missing string", + "description": 'The system is expecting a string value, such as following a concatenation operator ("&") but a string value was not found.', + }, + -782: { + "text": "Object value is Nothing", + "description": 'An object is being referenced in an expression of some type, and its value is not allocated and therefore undefined. To correct this problem, use the "New" qualifier in the DIM statement to allocate the value, and then define the required properties.', + }, + -783: { + "text": "Short string", + "description": "The number of characters stored in a string variable has been found to be less than is indicated by the string length. This is an abnormal condition and might occur if two threads are writing to the same string variable at the same time.", + }, + -784: { + "text": "Invalid property", + "description": "An object property is being accessed that is not valid given the settings of the other properties of the object. For example, an Exception object can be marked as a general error or a robot error. Depending upon this setting, certain properties are not accessible.", + }, + -785: { + "text": "Branch not permitted", + "description": "A GoTo instruction specifies a branch to a instruction that is not permitted. This typically means that a GoTo is attempting to branch into or out of a Try...Catch...Finally...End Try block that is not permitted. See the section on Exception handling for more information.", + }, + -786: { + "text": "Project generated error", + "description": "This error is never generated automatically and is provided solely as a convenience for GPL application programs. GPL projects can use the Throw instruction to emit this error code to indicate special application errors. The exception Qualifier can be used to provide additional information.", + }, + -787: { + "text": "Invalid in shared procedure", + "description": "An attempt has been made to access a non-shared and non-constant class variable from within a shared class procedure. Shared class procedures are not associated with an object instance, so they cannot access object variables. Either the procedure should not be declared Shared, or the class variable of interest should be declared Shared or Const.", + }, + -788: { + "text": "Inconsistent MOVE.TRIGGER mode", + "description": "Most likely, this indicates that a MOVE.TRIGGER instruction was executed that specified that the signal should be triggered a distance from the start or the end of the next motion, but the motion was not a straight-line or arc motion. To correct this problem, change the trigger mode or the motion type.", + }, + -789: { + "text": "Procedure exception", + "description": "A GPL procedure instruction has detected an exception that interrupts normal program execution. This error is normally handled internally by GPL and is not seen by the user.", + }, + -790: { + "text": "Invalid static initializer", + "description": "An attempt has been made to call a user-defined method in the initializer of a statically allocated variable or Const value. These variables include Module level variables, Shared class variables, and Static procedure variables. User-defined methods include constructors (New method) for user-defined classes.", + }, + -791: { + "text": "Conveyor must be base RefFrame", + "description": "Conveyor type RefFrame objects cannot themselves have a defined RefFrame value. That is, a Conveyor reference frame must always be the last (base) reference frame in a series of relative reference frames. However, other types of RefFrame objects can be relative to (above) Conveyor reference frames.", + }, + -792: { + "text": "undefined", + "description": "Before a Conveyor RefFrame can be used, its ConveyorRobot property must be set to specify the number of the Robot whose first axis provides the encoder signal for the conveyor. Without this information, the system has no way to determine the position of a conveyor.", + }, + -793: { + "text": "Arc cannot transition conveyors", + "description": "Circular interpolated motions can be performed relative to a conveyor belt. However, this type of motion cannot be used to accelerate on to a conveyor or off of a conveyor. That is, all three points that define a circular interpolated motion must all be relative to the same conveyor belt. To transition on to or off of a conveyor, use a straight-line Cartesian motion.", + }, + -794: { + "text": "ZClearance property not set", + "description": "A Move.Approach instruction was executed that referenced a Location whose ZClearance property has not been set to a realistic value. This instruction attempts to move the robot's tool to \"ZClearance\" mm above the Location's position. When a Location is first created, its ZClearance property is set to a very large number that cannot be reached. To correct this problem, set the Location's ZClearance property to the desired value in mm before executing the Approach instruction.", + }, + -795: { + "text": "XML documents do not match", + "description": "An attempt was made to define a parameter that is already defined. Only occurs in CommandProgram class methods.", + }, + -796: { + "text": "No delegate defined", + "description": "A reference has been made to an undefined delegate. Verify that any referenced delegate has been properly defined.", + }, + -797: { + "text": "Object not up-to-date", + "description": "An object that depends on another object or data structure has been referenced after the underlying data structure has been changed. Only occurs in CommandProgram class methods.", + }, + -798: { + "text": "No module defined", + "description": "An attempt has been made to declare a variable when no containing module has been defined. Only occurs in CommandProgram class methods.", + }, + -799: { + "text": "XML error", + "description": "An XML library error has occurred while parsing, accessing, or storing an XML document. This error is accompanied by an error code which further qualifies the error. See the XmlDoc.Message method for a string value that shows the error details.", + }, + -800: { + "text": "No XML document", + "description": "An attempt has been made to access an XmlDoc class object that is not associated with any document. Use the XmlDoc LoadString, LoadFile, or New methods to create a document.", + }, + -801: { + "text": "No XML node", + "description": "An attempt has been made to access an XmlNode class object that is not associated with any document node. This dissociation can occur if the referenced node is removed while the XmlNode object is still pointing to it. Check your program flow to see if nodes are being removed when you do not expect it. Remember that removing a parent may remove its children also.", + }, + -802: { + "text": "Undefined XML name", + "description": "A search for a named node using an XmlNode method has failed to find the named node. If you are not sure whether or not the node exists, use the HasElement or HasAttribute methods to check or enclose your search within a Try / Catch block.", + }, + -803: { + "text": "Invalid XML node type", + "description": 'An attempt has been made to use an XML node of a particular type where it is not allowed. For example, the parents of "attribute" nodes must be "element" nodes, and the parents of "element" nodes must be nodes of type "element", "entity", "document" or "htmldocument".', + }, + -804: { + "text": "XML documents do not match", + "description": "An attempt was made to add a XML node that is a member of one document to a second, different document. This is not permitted. Use the XmlNode.Clone method, with the second parameter specified, to create a clone of the node on an alternate document. You can add the clone to the second document.", + }, + -805: { + "text": "Invalid circular reference", + "description": "A Const symbol declaration refers to other Const symbols in a circular manner such that the original symbol references itself. This is not permitted.", + }, + -806: { + "text": "Invalid Const reference", + "description": "A Const symbol declaration refers to non-constant values, such as user variables or functions. This is not permitted. Const symbols may only be defined in terms of constants, other Const symbols, and built-in system functions.", + }, + -807: { + "text": "Invalid exception", + "description": "The exception object parameter to a Throw statement is invalid. Probably the ErrorCode property of the exception is not set to a negative value. Verify that you are setting the ErrorCode property to a negative value after creating the exception object.", + }, + -808: { + "text": "Branch out of Finally block not permitted", + "description": "An attempt has been made to exit from the Finally block of a Try … End control structure by using a statement such as a Goto, End, Exit, or Return. Finally blocks must always continue to the End Try statement.", + }, + -809: { + "text": "Expression too complex", + "description": "An attempt had been made to evaluate an expression that contains more the 32 objects, or that contains object references in a recursive loop. For example, a reference frame that refers to itself. Break the expression into multiple statements or remove any recursive references.", + }, + -810: { + "text": "Unexpected end of line", + "description": "The compiler has encountered the end of a line at an unexpected place, for example in the middle of an incomplete expression. Correct the syntax of the indicated line.", + }, + -811: { + "text": "No Set Property defined", + "description": "A Property definition has been encountered that is not declared ReadOnly and does not contain a Set statement. Either add a Set … End Set block or add the ReadOnly keyword to your Property definition.", + }, + -812: { + "text": "Not allowed in factory test system", + "description": "A special limited functionality version of GPL is loaded into the controller and one of the eliminated functions has been invoked. Replace the GPL system with the latest version available on the Precise Support website.", + }, + -1009: { + "text": "No robot attached", + "description": "A function or method was executed that required that a robot be ATTACHED. This error is often generated if you attempt to execute one of the methods in the Move Class without first attaching the robot. Correct your GPL program by inserting a Robot.Attached method prior to executing the instruction that contains the Move Class method.", + }, + -1000: { + "text": "Invalid robot number", + "description": "A robot number has been specified that is less than 1 or more than the number of configured robots.", + }, + -1001: { + "text": "Undefined robot", + "description": 'A robot number must be specified (1 to N), but no number was defined. For example, this error can occur if you are referencing a Parameter Database value that requires that a robot be specified as the "unit" (second) parameter but this value is left blank.', + }, + -1002: { + "text": "Invalid axis number", + "description": "An axis number has been specified that is less than 1 or more than the number of axes configured in the referenced robot. For example, this error will be generated if you are accessing a location's angle value (location.angle(n)) but the axis number is undefined or set to 0.", + }, + -1003: { + "text": "Undefined axis", + "description": "An axis has been specified that is not configured for the referenced robot. This can occur if an axis bit mask has been specified that references an axis that is not currently configured.", + }, + -1004: { + "text": "Invalid motor number", + "description": "A motor number has been specified that is less than 1 or more than the number of motors configured in the referenced robot.", + }, + -1005: { + "text": "Undefined motor", + "description": "A motor has been specified that is not configured for the referenced robot. This can occur if a motor bit mask has been specified that references a motor that is not currently configured.", + }, + -1006: { + "text": "Robot already attached", + "description": "An Auto Execution task or a GPL project has attempted to gain control of a robot by executing a Robot.Attach method or similar function, but the specified robot is already in use by another task. Alternately, this error is generated if you attempt to Attach or Select a robot, but the task is already attached to a different robot.", + }, + -1007: { + "text": "Robot not ready to be attached", + "description": 'An Auto Execution task or a GPL project has attempted to gain control of a robot by executing a Robot.Attach method or similar function, but the specified robot is not in a state where it can be attached. If this was generated by a GPL project, it might indicate: That the system is configured to execute DIOMotion blocks instead of a GPL motion program (DataID 200) or "Auto start auto execute mode" (DataID 202) is not set to TRUE. If so, please check the Setup>Startup Configuration web page to verify the current setup.', + }, + -1008: { + "text": "Can't detached a moving robot", + "description": "An operation is attempting to Detach a robot that is currently moving. Normally, it is not possible to generate this error condition because the Robot.Attached method automatically waits for the robot to stop before attempting the detach operation.", + }, + -1010: { + "text": "No robot selected", + "description": 'A function or method was executed that required that the robot be SELECTED. By default, the first robot or the Attached robot is set as the selected robot. A number of "readonly" operations require that a robot be selected, e.g. location.Here. Correct your GPL program by inserting a Robot.Selected method prior to executing the instruction that generated the exception.', + }, + -1011: { + "text": "Illegal during special Cartesian mode", + "description": "The robot is performing a special Cartesian trajectory mode such as conveyor tracking, outputting a DAC signal based upon the Cartesian tool tip speed, performing real-time trajectory modification, etc. When the trajectory generator is in this mode, the requested operation that produced this error is not permitted to execute. For example, jogging or moving along a joint interpolated trajectory or changing the tool length cannot be performed while tracking a conveyor belt. You must either terminate the current Cartesian trajectory mode with a Robot.RapidDecel or other means before initiating the new robot control mode or you must modify your program to use a method consistent with the current trajectory mode. For example, if you attempted to initiate a joint interpolated motion, use a Cartesian straight-line motion instead.", + }, + -1012: { + "text": "Joint out-of-range", + "description": "This indicates that the specified robot axes are either beyond or were attempted to be moved beyond their software limits, i.e. outside of their permitted ranges of travel. This error is also generated if you attempt to set the minimum and maximum soft and hard joint limits to inconsistent values. If you are narrowing the limits, you should set the new soft limits first and then change the hard limits. If you attempt to make both changes at the same time with the web interface, the system will process the new hard limits first, determine that they violate the old soft limits, and generate this error message.", + }, + -1013: { + "text": "Motor out-of-range", + "description": "This indicates that the specified robot motors are either beyond or were attempted to be moved beyond their software limits. This error is also generated if you attempt to set the minimum and maximum soft and hard motor limits to inconsistent values. For most robots, this error code will never be generated because the joint limits will be used exclusively. However, some robot's have coupled motors such that it may be possible to not violate a joint limit and still encounter the extreme travel limit of a motor. In these cases, this error message may be generated even when it appears that the robot's joint's are within their permitted ranges of travel.", + }, + -1014: { + "text": "Time out during nulling", + "description": 'At the end of a program generated motion, if the axes of the robot take too long to achieve the "InRange" constraint limits, this error message will be generated and program execution will be terminated. This may indicate that the InRange limit has been set too tightly or that the robot may not be able to get to the specified final position due to an obstruction.', + }, + -1015: { + "text": "Invalid roll over spec", + "description": "The Continuous Turn (encoder roll-over compensation) angle (DataID 2302) was set non-zero for an axis and the axis is not designed to support continuous turning. Please review the information for your kinematic module in the Kinematic Library documentation and ensure that the axis has been designed to support this feature.", + }, + -1016: { + "text": "Torque control mode incorrect", + "description": "Either the system is in torque control mode and should not be for the currently execution instruction or the system should be in torque control mode but is not. This can be generated in the following situations: Torque control mode is active and an instruction is executed to start External Trajectory mode, Jog Control mode, or torque control mode. An instruction is issued to set the torque values, but no motors are in torque control mode.", + }, + -1017: { + "text": "Not in position control mode", + "description": "A motion control instruction was initiated that requires that the robot be in the standard position controlled mode and the robot is in a special control mode, e.g. velocity control or jogging mode. For example, to initiate any of the following, the robot must be in the standard position control mode: torque control mode, velocity control mode, jog mode or any of the Move Class position controlled methods (e.g. Move.Loc).", + }, + -1018: { + "text": "Not in velocity control mode", + "description": "This error is generated if an instruction attempts to set the velocity mode speeds of an axis, but the system is not in velocity control mode.", + }, + -1019: { + "text": "Timeout sending servo setpoint", + "description": "The GPL trajectory generator sends a setpoint to the servos at the time interval determined by DataID 600, (Trajectory Generator update period in sec). This error occurs if a new setpoint is ready but the previous setpoint has not been sent. Verify that the DataID 603 (Servo update period in sec) value is one-half or less than the value of DataID 600 (Trajectory Generator update period in sec). In a servo network system, this error may indicate a network failure or unexpected network congestion. Provided that DataID 600 and 603 are set properly, this error should never be seen in a non-servo-network system. If the problem persists, contact Precise.", + }, + -1020: { + "text": "Timeout reading servo status", + "description": 'The servos send status information to GPL at the time interval determined by DataID 600, (Trajectory Generator update period in sec). This error occurs if no status information has been received by GPL for the past 32 milliseconds. If this error occurs when you are configuring a new controller system, it might indicate that you have changed some system parameters that are marked as "Restart required". However, you have attempted to operate the system without rebooting the controller. This can be corrected by restarting the controller. In a servo network system, this error may indicate a network failure or unexpected network congestion. This error should never be seen in a properly configured non-servo-network system. If the problem persists, contact Precise.', + }, + -1021: { + "text": "Robot not homed", + "description": 'An operation was invoked that requires that the robot\'s motors be homed. If a robot is equipped with incremental encoders, when the controller is restarted, the system does not have any knowledge of where each axes is located in the workspace. Homing establishes a repeatable "zero" position for each axis. The robot can be homed by pressing a button on the web Operator Control Panel, the web Virtual Manual Control Panel or via a program instruction.', + }, + -1022: { + "text": "Invalid homing parameter", + "description": "While executing the homing sequence an invalid parameter was encountered. This indicates that one of the Parameter Database values that controls homing (DataID 28xx) has been incorrect set. For example, an illegal homing method may be specified (DataID 2803) or the homing speed may be zero (DataID 2804).", + }, + -1023: { + "text": "Missed signal during homing", + "description": "During the homing operation with the robot moving, an error was detected prior to finding the signal that was expected. The detected error is most likely caused by an unexpected hardstop encountered or a over-travel limit switch tripping.", + }, + -1024: { + "text": "Encoder index disabled", + "description": 'This is normally generated by the homing routines. If a selected homing method tests for an encoder zero index signal and the encoder index is not enabled, this error is generated. This typically occurs if the "Encoder counts used for resolution calc" (DataID 10203) or the "Encoder revs used for resolution" (DataID 10204) are not properly setup.', + }, + -1025: { + "text": "Timeout enabling power", + "description": 'A request to enable robot power has failed to complete within the timeout period. Either a hardware failure has prevented power from coming on, or the timeout period is too short. Check the System Messages on the web interface Operator Control Panel for additional errors that may indicate a hardware failure. Verify that the controller is properly cabled. Try increasing the value of parameter "Timeout waiting for power to come on in sec" (DataID 262). This error may also occur as a GPL exception if power does not come on when the Controller.PowerEnabled method is used with a non-zero timeout parameter.', + }, + -1026: { + "text": "Timeout enabling amp", + "description": 'Robot power has been turned off during the robot power-on sequence because one or more power amplifiers have not become ready within the timeout period. Please try the following procedures: Check the System Messages on the web interface Operator Control Panel for additional errors that may indicate a hardware failure. Verify that the controller is properly cabled. Try increasing the value of parameter "Timeout waiting for amps to come on in sec" (DataID 264). If this occurs when the controller is first booted, try repeating the enable power operation after delaying for 1 minute. This error message can be generated if the robot includes absolute encoders that take a minute or so to complete their initialize before becoming ready to operate.', + }, + -1027: { + "text": "Timeout starting commutation", + "description": 'Robot power has been turned off during the robot power-on sequence because one or more motors have not completed their commutation sequence within the timeout period. Check the System Messages on the web interface Operator Control Panel for additional errors that may indicate a hardware failure. Try increasing the value of parameter "Timeout waiting for commutation in sec" (DataID 266). Verify that the controller is properly cabled and that the encoders and motors are wired correctly. See documentation section First Time Mechanism Integration and verify that the controller commutation parameters are set correctly for your motor and encoder combination. Verify that the FPGA firmware on the controller supports your motor and encoder combination.', + }, + -1028: { + "text": "Hard E-Stop", + "description": 'A hard E-Stop condition has been detected. Any robot motion in progress is stopped rapidly and robot power is turned off. One the following has occurred: A front panel E-Stop loop ("ESTOP_L 1" or "ESTOP_L 2") has been broken. The digital input signal specified by "Hard E-Stop DIN" (DataID 244) has been asserted. The parameter database item "Hard E-Stop" (DataID 243) has been set to TRUE. A "fatal" or "severe" error is detected and GPL automatically internally asserts an E-stop as a safety precaution to ensure that motor power is disabled. When a hard e-stop is asserted for any reason, if the "E-stop delay" (DataID 267) is set too short, it is possible that a "Amplifier under-voltage" error (-3109) will also be generated due to the DC motor bus voltage dropping before the amplifiers are disabled.', + }, + -1029: { + "text": "Asynchronous error", + "description": "An error signal from the servos or trajectory generator has been received by GPL, but no specific error code has been received. The error log entry immediately following this one normally indicates the actual error. Error signaling within the motion subsystem is a two-step process. When an error is first detected, a signal is sent to GPL immediately so that a controlled deceleration sequence can begin. This first signal generates a -1029 error code. Several milliseconds later, a more specific error code is sent to identify the source of the error. This second error code overwrites the -1029 error. Error -1029 is only seen if the error log is sampled after the initial error signal is received and before the specific error code is received.", + }, + -1030: { + "text": "Fatal asynchronous error", + "description": 'A severe error signal from the servos or trajectory generator has been received by GPL, but no specific error code has been received. The error log entry immediately following this one normally indicates the actual error. Error signaling within the motion subsystem is a two-step process. When a severe error is first detected, a signal is sent to GPL immediately so that a controlled deceleration sequence can begin. This first signal generates a -1030 error code. Several milliseconds later, a more specific error code is sent to identify the source of the error. This second error code overwrites the -1030 error. Error -1030 is only seen if the error log is sampled after the initial error signal is received and before the specific error code is received. Unlike standard errors, a severe error prevents robot power from being enabled until the controller is rebooted or "Reset fatal error" (DataID 247) is set to 1.', + }, + -1031: { + "text": "Analog input value too small", + "description": 'When reading an analog input signal, if after the scale and offset is applied, the value of the signal is lower than the limit set by "Gen AIO In min scaled value" (DataID 526), the analog value is set to the minimum value and this error is generated.', + }, + -1032: { + "text": "Analog input value too big", + "description": 'When reading an analog input signal, if after the scale and offset is applied, the value of the signal is higher than the limit set by "Gen AIO In max scaled value" (DataID 527), the analog value is set to the maximum value and this error is generated.', + }, + -1033: { + "text": "Invalid Cartesian value", + "description": "For robots with less than 6 independent degrees-of-freedom, certain combinations of tool orientations and positions are not possible. This does not indicate an axis limit stop error such as when a program attempts to move a linear axis beyond its end of travel. This error refers to positions and orientations that are not possible even if each axis of the robot has an unlimited range of motion. For example, for a 4-axis Cartesian robot with a theta axis, the tool cannot be rotated about the world X or Y axis. So if an instruction has a destination that requires that the tool be moved from pointing down to pointing up, this error will be generated.", + }, + -1034: { + "text": "Negative overtravel", + "description": "This is generated when the optional negative travel limit hardware switch has been tripped. This error indicates that a specified axis it outside of its permitted range-of-motion.", + }, + -1035: { + "text": "Positive overtravel", + "description": "This is generated when the optional positive travel limit hardware switch has been tripped. This error indicates that a specified axis it outside of its permitted range-of-motion.", + }, + -1036: { + "text": "Kinematics not installed", + "description": 'An operation was invoked that requires that the system be able to convert between joint and Cartesian (XYZ) coordinates. However, the system software has not been configured with a robot kinematics (geometry) module. The kinematic modules are selected using the "Robot types" (DataID 116) and are described in the Kinematics Library section of the PreciseFlex Library. Select an appropriate module and restart the system. If there is not an appropriate kinematics module, contact Precise.', + }, + -1037: { + "text": "Motors not commutated", + "description": "The operation that was attempted may not require that the robot's motors be homed, however, they must have their commutation reference established. Setting the commutation reference provides the controller with the knowledge of how to energize the individual motor phases as the motor rotates. For example, motors can be placed into torque control mode after they have been commutated and without homing the motors.", + }, + -1038: { + "text": "Project generated robot error", + "description": "This error is never generated automatically and is provided solely as a convenience for GPL application programs. GPL projects can use the Throw instruction to generate this special error to indicate special application errors that are robot specific.", + }, + -1039: { + "text": "Position too close", + "description": "This indicates that an XYZ destination is too close to the center of the robot and cannot be reached. For example, your robot may have an inner and an outer rotary link that dictates the radial distance of the gripper. If the outer link is shorter than the inner link, there will be a circular region at the center of the robot that cannot be accessed. In other cases, if the inner and outer link are the same lengths, the robot may be able to reach the center position, but such a position might be a mathematical singularity where two or more joints degenerate into the same motion. These types of conditions are signaled by this error code.", + }, + -1040: { + "text": "Position too far", + "description": "This indicates that an XYZ destination is beyond the robot's reach. This is typically generated by robot's with rotary links where the position is beyond the range of the fully outstretched links. To avoid excessive joint rotation speeds, this error may be signaled a few degrees before the fully extended position in manual jog mode or when the robot is moving along a straight-line path.", + }, + -1041: { + "text": "Invalid Base transform", + "description": "The Base transformation for a robot is required to have a zero pitch value. That is, a Base transform can translate the robot in all three directions and can rotate the robot about the world Z-axis, but it can not rotate the robot about the world X-axis or Y-axis.", + }, + -1042: { + "text": "Can't change robot config", + "description": "A motion was initiated that attempted to change the robot configuration (e.g. Righty vs. Lefty) when such a change is not permitted. For example, you cannot change the robot configuration during a Cartesian straight-line motion. Normally, if you specify a motion destination as a Cartesian Location, any differences in configuration are ignored. However, if you specify a Angles Location as the destination for a Cartesian motion and the Angles correspond to a different configuration, this error will be signaled.", + }, + -1043: { + "text": "Asynchronous soft error", + "description": "A soft error signal from the servos or trajectory generator has been received by GPL, but no specific error code has been received. The error log entry immediately following this one normally indicates the actual error. Error signaling within the motion subsystem is a two-step process. When an error is first detected, a signal is sent to GPL immediately so that a controlled deceleration sequence can begin. This first signal generates a -1043 error code. Several milliseconds later, a more specific error code is sent to identify the source of the error. This second error code overwrites the -1043 error. Error -1043 is only seen if the error log is sampled after the initial error signal is received and before the specific error code is received. Unlike standard errors, a soft error does not disable robot power.", + }, + -1044: { + "text": "Auto mode disabled", + "description": 'This indicates that: (1) the Auto/Manual hardware input signal has been switched to Manual and motor power has been disabled or (2) an automatic program controlled motion was attempted, but the Auto/Manual hardware input signal is set to Manual. When the Auto/Manual signal is set to Manual, only Jog ("Manual control") mode is permitted.', + }, + -1045: { + "text": "Soft E-STOP", + "description": 'A soft E-Stop condition has been detected. Any robot motion in progress is stopped rapidly but robot power is left on. One of the following has occurred: The GPL property Controller.SoftEStop has been set to TRUE. The GPL console command SoftEStop has been executed. The digital input signal specified by "Soft E-Stop DIN" (DataID 246) has been asserted. The parameter database item "Soft E-Stop" (DataID 245) has been set to TRUE.', + }, + -1046: { + "text": "Power not enabled", + "description": "An operation was attempted, such as trying to Attach to a robot, and power to the robot is required but has not yet been enabled. Enable high power and repeat the operation.", + }, + -1047: { + "text": "Virtual MCP in Jog mode", + "description": "An operation was attempted, such as trying to Attach to a robot, but the robot is already attached and is being controlled in Jog control mode via the web based Virtual MCP. Go to the Web Virtual MCP, place the robot into computer control mode, and retry the operation.", + }, + -1048: { + "text": "Hardware MCP in Jog mode", + "description": "An operation was attempted, such as trying to Attach to a robot, but the robot is already attached and is being controlled in Jog control mode via the hardware MCP. Go to the MCP, place the robot into computer control mode, and retry the operation.", + }, + -1049: { + "text": "Timeout on homing DIN", + "description": 'During the homing operation, a digital input signal that is specified for a motor via the "Wait to home axis DIN" (DataID 2812) failed to turn on before the timeout period defined by the "Timeout on home axis, sec" (DataID 2813) expired.', + }, + -1050: { + "text": "Illegal during joint motion", + "description": "An operation or program instruction has been executed that requires that the robot either be stopped or moving in a Cartesian control mode. However, the robot is executing a program controlled joint interpolated motion. Wait for the joint interpolated motion to terminate before executing this instruction.", + }, + -1051: { + "text": "Incorrect Cartesian trajectory mode", + "description": "An operation or program instruction has been executed that requires that the Trajectory Generator be in a specific Cartesian mode, but the mode was incorrect. Some possible problems could be: An instruction attempted to start the Real-time Trajectory Modification mode, but this mode is already active. An instruction attempted to set Real-time Trajectory Modification parameters, but this mode is not active.", + }, + -1052: { + "text": "Beyond conveyor limits", + "description": "Indicates that the position for a motion being planned is beyond the upstream or downstream limits of the referenced conveyor belt. This error is generated in the following circumstances: A motion is being planned that is relative to a conveyor belt and the final position is projected to be out of the conveyors downstream limit before the robot can reach this position. If a position is upstream of the upstream limit, the system pauses thread execution until the position comes within the limits and no error is generated.", + }, + -1053: { + "text": "Beyond conveyor limits while tracking", + "description": "Indicates that a position is beyond the upstream or downstream limits of a conveyor belt while the robot is tracking the belt. This error is generated in the following circumstances: The robot is moving relative to a conveyor belt and the final destination for the motion or the instantaneous position is beyond either limit of the conveyor belt.", + }, + -1054: { + "text": "Can't attach Encoder Only robot", + "description": "An Auto Execution task (such as that for the Manual Control Pendant) or a GPL project has attempted to gain control of a robot by executing a Robot.Attach method or similar function, but the specified robot is an Encoder Only module. This type of kinematic module can be accessed to read the position of an encoder, but cannot be used to drive the encoder. Therefore, it is not legal to Attach this type of robot. Use the Robot.Selected method instead, if you only need read-only access to the robot.", + }, + -1055: { + "text": "Cartesian motion not configured", + "description": 'This error is generated if you attempt to perform a Cartesian motion either in manual control or program control mode, and some key Cartesian motion parameters have not been initialized. Typically, this is caused by: The "100% Cartesian speeds" (DataID 2701) being set to 0 instead of their proper positive non-zero values. The "100% Cartesian accels" (DataID 2703) being set to 0 instead of their proper positive non-zero values.', + }, + -1056: { + "text": "Incompatible robot position", + "description": "The current robot position is not compatible with the requirements of the operation that has been initiated. Situations where this error can be generated include: For the RPRR kinematic module, the two yaw axes have been defined to move at a fixed offset relative to each other. However, at the start of a straight line motion, the yaw axes are not in proper alignment.", + }, + -1057: { + "text": "Timeout waiting for front panel button", + "description": 'For Category 3 (CAT-3) systems, the front panel "High Power Enable" button or the digital input specified by DataID 268 must transition from low to high within 30 seconds of a power-on request for high power to actually be enabled. This error is generated if the low to high transition is not seen within this time period.', + }, + -1058: { + "text": "Commutation disabled by servos", + "description": "This error indicates that the servos have invalidated the motor commutation in response to an error condition. For example, a severe encoder error may have occurred. Check the Error Log for additional error messages that indicate why commutation was disabled. In most cases, re-enabling robot power causes the motor commutation to be re-established and this error to be cleared.", + }, + -1059: { + "text": "Homed state disabled by servos", + "description": "This error indicates that the servos have marked an axis as not homed in response to an error condition. For example, a severe encoder error may have occurred. Check the Error Log for additional error messages that indicate why homing was invalidated. You cannot operate the axis under program control until you re-execute the axis homing procedure. You can use the MCP in joint control mode to move an axis that is not homed.", + }, + -1060: { + "text": "Remote E-STOP (node n)", + "description": 'Indicates that servo network node n is asserting an E-Stop condition. Whether E-Stop is connected to the main controller or to a remote node depends on your robot model and configuration. See the explanation for error -1028 "Hard E-Stop" for possible reasons for this error.', + }, + -1061: { + "text": "Illegal when robot moving", + "description": "This error is generated when a robot is moving under computer controller and an operation is performed that requires that the robot be stopped. For example, this occurs if the Set Payload console command is issued while the robot is moving.", + }, + -1062: { + "text": "Certified Safety Zones not supported", + "description": "This error indicates that the robot's PAC files include the specification of a Safety Certified Safety Zone, but the robot does not support this feature. These safety zones ensure that the robot does not exceed certain speed limits within specified physical areas. Typically, this error occurs if the wrong type of safety zone is specified or if the robot's safety certification functions are not operational but should be.", + }, + -1063: { + "text": "Certified Safety Zones cannot be rotated", + "description": "This error indicates that the robot's PAC files include the specification of a Safety Certified Safety Zone, but the orientation of the zone is rotated. For computational reasons, Certified Safety Zones must always be non-rotated rectangular volumes. Ensure that the Yaw, Pitch, and Roll parameters for the safety zone are zero. Alternately, use an Uncertified Safety Zone if you do not require a safety certified operation and would like to use a rotated safety zone.", + }, + -1064: { + "text": "Tool tip violates Safety Zone", + "description": 'This error indicates that the robot\'s tool tip (TCP) violates one or more defined Uncertified Safety Zones. A violation will occur if the TCP enters a "keep out" or exits a "keep in" safety zone. This error can occur if the end position of a program-controlled motion is in error, anytime during a Cartesian or joint programmed controlled motion, or anytime during a World, Tool, or Joint manual control motion. Once a violation has occurred, the robot must be backed out when high power is disabled or by using manual control Free mode or by using manual control Joint, Tool, or World mode (so long as the manual jog motion reduces the violation of the safety zone).', + }, + -1065: { + "text": "Tool tip speed too high in Safety Zone", + "description": "This error indicates that the robot's tool tip (TCP) is either moving down too fast in Z or moving too fast in the XY plane in a defined speed-limited non-rotated rectangular Safety Certified Safety Zone. Robots like the PreciseFlex 300/400/3400 can operate safely at their highest speed throughout their working volume, except when the tool tip is moving down and might pin an operator's hand to a hard surface. Additionally, robots like the PreciseFlex DD need to limit their horizontal speed when they are on near-vertical surfaces. If this error occurs, reduce the speed of the robot before it enters these safety zones and retry the motion. Please consult the operating manuals for these robots to determine when certified speed-limited safety zones must be used to ensure the safe operation of these robots.", + }, + -1066: { + "text": "Invalid Data ID code", + "description": "The Data ID code and the specified index values do not form a valid data reference. Check that the appropriate index values are specified with this Data ID.", + }, + -1501: { + "text": "Unknown Data ID code", + "description": "The Data ID code specified is not known by GPL. Check that the Data ID is valid. Some Data ID codes only become known if certain software options are enabled or configurations are selected. For example, you cannot specify absolute encoder parameters if you do not have any absolute encoders configured. Check that you are not specifying codes for a component that is not configured.", + }, + -1502: { + "text": "Inconsistent duplicate Data ID code", + "description": "For networked servo systems, the definitions for a Data ID must be the same for the master and all slave nodes. This error indicates some differences were detected. Verify that compatible software is loaded on the master node and all slave nodes.", + }, + -1505: { + "text": "Uninitialized parameter database", + "description": "The parameter database was not initialized properly at system startup. This error indicates an internal system error and should never been seen.", + }, + -1507: { + "text": "Must be master node", + "description": "For networked servo systems, some data and operations are only allowed on the master node. An attempt has been made to access such data or operations directly from a slave node.", + }, + -1509: { + "text": "Invalid data index", + "description": "The data index value specified in a Data ID reference is invalid. Probably an array index has been specified that is greater than the actual array size.", + }, + -1510: { + "text": "Database internal consistency error", + "description": "An internal error has occurred during a database reference. The parameter specified along with the Data ID may not be valid. This error should never be seen.", + }, + -1511: { + "text": "Invalid pointer value", + "description": "An internal error has occurred during a database reference. The parameter specified along with the Data ID may not be valid. This error should never be seen.", + }, + -1512: { + "text": "Undefined callback routine", + "description": "An internal error has occurred during a database reference. This error should never be seen.", + }, + -1513: {"text": "Invalid data from callback routine", "description": ""}, + -1514: { + "text": "Invalid parameter array index", + "description": "A parameter index value specified in a Data ID reference is invalid. Probably the robot number or other index value is too large.", + }, + -1515: { + "text": "Invalid data file format", + "description": 'Some or all of the data in a configuration ".pac" file could not be read because it did not have the expected format. Either the file has become corrupted or an attempt to manually edit the file has introduced some errors.', + }, + -1516: { + "text": "Invalid number of axes or motors", + "description": "The total number of axes or motors specified for a robot violates the number permitted. For example, this can occur if you specify 5 axes for the XYZTheta robot module. More subtly, if you configure the maximum number of motors supported by the system and then attempt to turn on split-axis control for one or axes, this requires that more motors be added.", + }, + -1517: { + "text": "Invalid signal number", + "description": "A specified analog or digital signal number is not within the allowed range of values for the signal type expected. Change the signal number to a valid value.", + }, + -1518: { + "text": "Undefined signal number", + "description": "A specified analog or digital signal is not currently installed in the controller. Remote signals may dynamically change between installed and uninstalled status depending on the state of the remote device. Verify that all remote devices are connected and active.", + }, + -1519: { + "text": "Output signal required", + "description": "An input signal has been specified when an output signal is required. Change the signal number to an appropriate value.", + }, + -1520: { + "text": "Can't open parameter DB file", + "description": "During initialization, one or more of the parameter database files (*.pac files) could not be found on the controller's flash drive in the folder /flash/config. Restore the files and reboot your controller.", + }, + -1521: { + "text": "Invalid parameter DB file not loaded", + "description": "A parameter database file (*.pac file) was found in the controller's flash drive folder /flash/config but the file format is not valid. Restore the file and reboot your controller.", + }, + -1522: { + "text": "Cannot write value when power on", + "description": "An attempt has been made to alter a Parameter Database value when motor power is enabled. However, the parameter can only be modified if motor power is disabled. This is a safety precaution to ensure that the robot does not move erratically when the value is modified. To avoid this error condition, disable motor power and retry modifying the Parameter Database value.", + }, + -1523: { + "text": "Coupled axis must be on same node", + "description": "Two coupled robot axes, for example two axes that are coupled in Split-axis control, have been defined to be driven by amplifiers that are contained on two different physical controllers that are connected on a Servo Network. This is not permitted. The two axes must be driven by amplifiers on the same physical controller.", + }, + -1524: { + "text": "Saving PDB values to flash not allowed", + "description": "A special command has been issued that inhibits any modified Parameter Database values that are in memory from being written to the flash disk. This command is typically issued by system programs that temporarily modify PDB values, which if written to the flash, would corrupt the valid data that is already contained on the flash. If you wish to modify PDB values and permanently save the changes, reboot the controller to clear this special mode.", + }, + -1525: {"text": "Servo network not allowed with EtherCAT", "description": ""}, + -1526: {"text": "EtherCAT configuration mismatch", "description": ""}, + -1527: {"text": "EtherCAT object not supported", "description": ""}, + -1528: {"text": "Too many EtherCAT slaves", "description": ""}, + -1550: { + "text": "Data ID cannot be logged", + "description": "A data log specification refers to a Data ID that cannot be logged. Some values require too much computation to permit real-time logging.", + }, + -1551: { + "text": "Invalid when datalogger enabled", + "description": "An attempt has been made to perform an operation that is not valid while the datalogger is active. Disable the datalogger and try again.", + }, + -1552: { + "text": "Datalogger not initialized", + "description": 'An attempt has been made to start the datalogger before it has been initialized. Normally this error is not seen if the web interface is used for logging. It may be seen if database parameter "Data logger enable" (DataID 753) is set before parameter "Data logger load command" (DataID 752) is set.', + }, + -1553: { + "text": "No data items defined", + "description": "An attempt has been made to start the datalogger before defining any items to log.", + }, + -1554: { + "text": "Trigger Data ID must be logged", + "description": "A datalogger trigger specification refers to a Data ID that is not specified as a logged item. Triggers can only specify Data ID values that are also being logged.", + }, + -1555: { + "text": "Trigger Data IDs on different nodes", + "description": "In a networked servo system, a datalogger trigger specification attempted to compare two Data IDs whose values exist on different network nodes. A trigger can only compare items that are on the same node.", + }, + -1556: { + "text": "Trigger not allowed for Data ID", + "description": "Some Data ID values cannot be used as trigger values. For example Cartesian positions or velocities are computed from logged data after logging is complete, so these values cannot be tested at logging time.", + }, + -1558: { + "text": "Too many remote data items", + "description": "In a servo network or GSB-based system, items collected by the datalogger from slave nodes are streamed from the slave to the master in real time. The amount of data that can be streamed depends on the trajectory period, the datalogger sampling interval, and the size and number of data items being logged from the slave. This error indicates that you have requested more data to be logged than can be streamed. Reduce the number of data items requested from the slave or increase the datalogger sampling interval.", + }, + -1560: { + "text": "Invalid when CPU Monitor enabled", + "description": "An attempt was made to start the CPU Monitor utility or read data from it when it was active. Wait until the current monitor interval is complete, or cancel the current monitor and try again.", + }, + -1561: { + "text": "No CPU Monitor data available", + "description": "An attempt was made to read the CPU Monitor data before the CPU Monitor utility was run. Execute the CPU Monitor utility and try again.", + }, + -1600: { + "text": "Power off requested", + "description": 'Robot power had been turned off by request. One of the following occurred: The GPL property Controller.PowerEnabled has been set to FALSE. The parameter database item "Power enable" (DataID 241) has been set to FALSE.', + }, + -1601: { + "text": "Software Reset: using default settings", + "description": 'When the system was restarted, the "Software Reset" switch on the MCIM Selector Switch was set to the ON state. This forced the system to read in the default configuration files (*.PAC) instead of the standard files. This feature should be used if a configuration files becomes corrupted or a setting inadvertently make the system unusable.', + }, + -1602: { + "text": "External E-STOP", + "description": 'A front panel E-Stop loop ("ESTOP_L 1" or "ESTOP_L 2") has been broken and the status signal "External ESTOP_L" is asserted, indicating that external equipment is the source of the E-Stop.', + }, + -1603: { + "text": "Watchdog timer expired", + "description": "The hardware watchdog timer on the CPU board has expired and robot power is disabled. This error may indicate a hardware failure or software bug and should not normally be seen. If this error persists, please contact customer service to report this problem.", + }, + -1604: { + "text": "Power light failure", + "description": "The robot power-on light interfaced via the front panel connector has burned out. Robot power is turned off and may not be turned back on. Please contact customer service.", + }, + -1605: { + "text": "Unknown power off request", + "description": "Robot power is off or was turned off, but no request was made to turn it off. This error may indicate a hardware failure or software bug and should not normally be seen.", + }, + -1606: { + "text": "E-STOP stuck off", + "description": "When the controller is restarted, a diagnostic program has detected an E-Stop circuit that is stuck in the off state. Robot power cannot be turned on until the stuck E-Stop circuit is repaired and the controller is restarted.", + }, + -1607: { + "text": "Trajectory task overrun", + "description": 'The periodic trajectory generation system task was unable to complete its executing during its allotted period. The parameter "Trajectory Generator update period in sec" (DataID 600) is set too small for the type of robot you are using.', + }, + -1609: { + "text": "E-STOP timer failed", + "description": "When the controller is restarted, a diagnostic program has detected that the hardware E-Stop timer is not triggering as expected. Robot power cannot be turned on and you must restart the controller. If this error persists, please contact customer service to report this problem.", + }, + -1610: { + "text": "Controller overheating", + "description": 'In GPL 3.0 H1 and later, this error is reported in addition to -1617 CPU overheating, -3144 Amplifier overheating or -3145 Motor overheating. These other errors provide detailed information on the specific component that is generating the overheating error condition. Prior to GPL 3.0 H1, this is the only error message that is generated when the CPU or one of the power amplifiers has exceeded its permitted operating temperature. For the CPU, the limit is 90 degrees Celsius. For the amplifiers of the G3xxx and G1xxxA/B controllers (except for the G3x3xA 30A version), the limit is 80C. For the amplifiers of the G3x3xA 30A version, the limit is 100C. For the G2xxx controllers, the amplifier chips are equipped with internal temperature monitoring to protect the power modules, but they do not have temperature sensors that can be read. If the CPU is overheating, it will switch off in 5 minutes and then you will need to reboot the controller. For all versions of software, robot power is automatically turned off and cannot be turned on until the overheated device cools. In a servo network, the overheated device may reside in the master controller or one of the slaves. This error is issued each time a command to enable robot power is received until all temperatures return to acceptable levels. DataIDs 126, 12605 and 12110 contain the "CPU temperature" and "Amp temperature", and "Motor temperatures" respectively, and can be displayed to determine which device is overheating.', + }, + -1611: { + "text": "Auto/Manual switch set to Manual", + "description": 'A attempt has been made to enable robot power, using one of the standard means, when the Auto/Manual switch is set to Manual. The robot power has not been enabled. The standard means of enabling power are by using: the PowerEnabled property of the Controller class in GPL, the "Enable Power" parameter (DataID 241), the "Automatic power on" parameter (DataID 240), the power enable digital input (defined by DataID 242), or CANopen.', + }, + -1612: { + "text": "Power supply relay stuck", + "description": 'An attempt to turn on power has failed because an internal diagnostic test indicates that the motor power supply relay is stuck in the "on" position and may be unsafe. Reboot your controller. If this error persists, contact customer service.', + }, + -1613: { + "text": "Power supply shorted", + "description": "Power has been disabled because the motor power supply has detected that it is shorted. Check the wiring and the amplifiers to make sure no short is present.", + }, + -1614: { + "text": "Power supply overloaded", + "description": 'Power has been disabled because the motor power supply has detected an overload condition. Verify that you are not attempting to draw more than the rated current from the power supply. Verify that the parameter "Delay after turning on power in sec" (DataID 263) is set long enough to allow power to stabilize before the amplifiers are enabled.', + }, + -1615: { + "text": "No 3-phase power", + "description": "A power supply has been configured to use 3-phase power, but the 3rd phase is not connected or has failed. It is still possible to run the power supply in this mode at low power, but attempting to draw high power may overheat the power supply.", + }, + -1616: { + "text": "Shutdown due to overheating", + "description": 'The CPU exceeded its operating temperature for too long a period of time and the controller automatically turned off. When the CPU first exceeds its maximum permitted limit of 90 degrees Celsius, a "Controller overheating" error (-1610) is generated and motor power is disabled. If the temperature does not decline within 5 minutes, error code -1616 is logged to the flash in a special system file ("/flash/system/errlog.txt")and the controller is shutdown. The next time the controller is restarted, the -1616 error code will be displayed in the error log. This error code will be displayed every time the controller is restarted until the error log is cleared.', + }, + -1617: { + "text": "CPU overheating", + "description": 'Either the master or a slave CPU board temperature has exceeded its maximum temperature of 90 degrees Celsius. See CPU temperature (DataID 126) for the current temperature of all CPUs. If the temperature does not fall below 90 degrees, the controller will switch off in 5 minutes and the controller must be restarted. Normally this error is followed by the generic error -1610 "Controller overheating". Ensure there is good air circulation around the controller and check that the fans are not blocked. The Installation section of the controller hardware manuals provide guidelines for ventilating and cooling controllers.', + }, + -1618: { + "text": "Power supply not communicating", + "description": 'Some PreciseFlex™ power supplies communicate with the PreciseFlex™ controller for identification, error detection, and safety interlocks. This communications has failed. Be sure that all cables between the controller and power supply are installed properly. Verify that DataID 128 "Power supply type" is set properly for the type of power supply you are using.', + }, + -1619: { + "text": "Power disabled by GIO timeout", + "description": 'This errors occurs in two possible cases: If the error message indicates node 8 and the robot has a "safety board" (e.g. a PreciseFlex™ 3400), this error indicates that the safety board has stopped responding for 32 trajectory periods (approximately 0.128 seconds). This error disables the robot motor power and this error cannot be disabled. Otherwise, the error is related to the GIO board indicated by the node number in the error message. The GIO has stopped responding for 32 trajectory periods and the "GIO mode" (DataID 574) has bit mask 2 set, so that robot motor is disabled. If bit mask 2 is clear, GIO board timeouts do not disable robot motor power. This error might be due to: a wiring problem; improperly installed RS485 bus termination jumpers on the main CPU, Safety, GIO or GSB boards; or excessive noise in another cable, motor, or amplifier that is close to the RS485 cable. Issuing a "GSB Show" command from the System Web Console may provide additional information about the source of the error.', + }, + -1620: { + "text": "Safety diagnostics failed", + "description": 'In enhanced CAT3 mode, the safety diagnostic checks have failed. Robot power cannot be enabled. Previous error messages may indicate the cause of the failure. Use the "Show Safety" web console command for more details. Once the problem has been resolved, try to enable power again.', + }, + -1621: { + "text": "Safety software configuration mismatch", + "description": 'The safety configuration bits in DataID 2031 "Enhanced safety mode" do not match the settings of DataID 117 "Safety mode" or require hardware not present in your controller. For example, you set bit &H001 of DataID 2031 but DataID 117 is not set to 4 or 5 or you set bit &H200 in DataID 2031 but do not have a 3-phase power supply. Verify that DataID 117 is correct. For hardware-related errors, contact Brooks support. If a numeric value is displayed with this error, the value indicates what aspect of the safety configuration did not match. See DataID 2031 "Enhanced safety mode" for a description of the bits that form this value.', + }, + -1622: { + "text": "Not allowed in safety mode", + "description": 'You have attempted to perform some operation that is not allowed in your current safety mode. For example, you have attempted to enable power from a user program while DataID 117 "Safety mode" is set to 5 (Enhanced CAT-3 full).', + }, + -1623: { + "text": "ESTOP1 stuck on", + "description": "The safety diagnostics in enhanced CAT3 mode have determined that ESTOP channel 1 is asserted. Probably your channel 1 ESTOP loop is open. Close the loop and retry the operation.", + }, + -1624: { + "text": "ESTOP2 stuck on", + "description": "The safety diagnostics in enhanced CAT3 mode have determined that ESTOP channel 2 is asserted. Probably your channel 2 ESTOP loop is open. Close the loop and retry the operation.", + }, + -1625: { + "text": "ESTOP1 stuck off", + "description": "The safety diagnostics in enhanced CAT3 mode have determined that ESTOP channel 1 cannot be asserted by the internal force-ESTOP circuit. Your channel 1 ESTOP may be wired incorrectly.", + }, + -1626: { + "text": "ESTOP2 stuck off", + "description": "The safety diagnostics in enhanced CAT3 mode have determined that ESTOP channel 2 cannot be asserted by the internal force-ESTOP circuit. Your channel 2 ESTOP may be wired incorrectly.", + }, + -1627: { + "text": "Motor power stuck on", + "description": 'The safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is too high when the motor power has been turned off. This may indicate a failed internal safety circuit or an unplugged cable.', + }, + -1628: { + "text": "Motor power stuck off", + "description": 'The safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is too low when the motor power has been turned on. This may indicate a failed internal safety circuit or an unplugged cable.', + }, + -1629: { + "text": "FFC ENA_PWR' signal stuck on", + "description": 'In a controller that includes a FFC safety board, the safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is too high when the motor power has been turned off. Probably the FFC_ENA_PWR\' signal on the FFC board is stuck on or this signal from the controller is shorted high. When the safety board is present, the output from the motor power supply should be off when the controller commands that motor power should be disabled. If the output of motor power supply is on, there is a problem with the motor power disable signal and its associated circuits.', + }, + -1630: { + "text": "FFC ENA_PWR' signal stuck off", + "description": 'In a controller that includes a FFC safety board, the safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is too low when the motor power has been turned on. Probably the FFC_ENA_PWR\' signal on the FFC board is stuck off or this signal from the controller is shorted low.', + }, + -1631: { + "text": "Power dump circuit failed", + "description": 'The safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is not falling quickly enough when power is turned off. This indicates that the power dump circuit has failed or is missing. Verify that you are using the correct robot *.pac files with this robot.', + }, + -1632: { + "text": "At least one motor must be enabled", + "description": 'The safety diagnostics in enhanced CAT3 mode have determined that there are no motors enabled for the robot. At least one motor must be present to perform the safety tests. Check the values for DataID 2105 ("Simulate servo interface") and DataID 2026 ("Motor disable mask") to make sure at least one motor is enabled. If you really want all motors disabled, you must also disable safety mode using DataID 117.', + }, + -1633: { + "text": "Hardware watchdog timer failed to disable power", + "description": 'The safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is too high after the hardware watchdog timer has expired. There may be a hardware problem with the watchdog timer, or you have attempted to enable safety mode on an older controller that does not contain a hardware watchdog timer.', + }, + -1634: { + "text": "FPGA watchdog timer failed to disable power", + "description": 'The safety diagnostics in enhanced CAT3 mode have determined the DC voltage indicated by DataID 12684 ("Nominal DC bus voltage, volt") is too high after the watchdog timer in the FPGA has expired. There may be a hardware problem with the watchdog timer.', + }, + -1635: { + "text": "FPGA watchdog trigger stuck on", + "description": "The FPGA indicates that the watchdog timer is triggered even though it is being polled normally. There may be a hardware problem with the watchdog timer or the FPGA.", + }, + -1636: { + "text": "FPGA watchdog trigger stuck off", + "description": "The FPGA indicates that the watchdog timer is not triggered even though it is not being polled. There may be a hardware problem with the watchdog timer or the FPGA.", + }, + -1700: { + "text": "Cannot get local host name", + "description": "The Ethernet network is not configured properly. Check the parameter values for DataIDs 420, 421 and 422.", + }, + -1701: { + "text": "Cannot get local host address", + "description": "The Ethernet network is not configured properly. Check the parameter values for DataIDs 420, 421 and 422.", + }, + -1702: { + "text": "Connection refused", + "description": "An attempt to connect to a TCP server was refused. Be sure your IP address and port numbers are correct. Be sure the server is ready to accept connections.", + }, + -1703: { + "text": "No connection", + "description": "An attempt was made to send or receive on socket that was not connected to a TCP or UDP port.", + }, + -1704: { + "text": "Invalid network address", + "description": "The IP address specified cannot be accessed.", + }, + -1705: { + "text": "Network timeout", + "description": "A network operation did not complete within the allowed time period. Depending on the operation, the connection may or may not be closed.", + }, + -1706: { + "text": "Already connected", + "description": "An attempt was made to connect a socket to an IP Address and port when there is already a connection.", + }, + -1707: { + "text": "Socket not open", + "description": "An attempt was made to use a network socket that is not open.", + }, + -1708: { + "text": "Connection closed", + "description": "An attempted was made to use a socket whose connection has been closed either locally or by the remote endpoint.", + }, + -1709: { + "text": "Invalid protocol", + "description": "A message was received that does not follow a known communications protocol.", + }, + -1710: { + "text": "Invalid multicast address", + "description": "The IP address is not a valid multicast address. The recommended range for multicast IP addresses is from 239.192.0.0 through 239.195.255.255.", + }, + -1720: { + "text": "Web interface not enabled", + "description": 'An external system attempted to access one of the controller\'s web pages, but access via the web server has been disabled. To enable the web interface, modify the value of the "Web password security" (DataId 450) database parameter.', + }, + -1730: { + "text": "Modbus/RIO exception: n", + "description": "A MODBUS or RIO board request failed with exception code n. The standard MODBUS exception codes are: 1 = Illegal function, 2 = Illegal data address, 3 = Illegal data value, 4 = Device has failed.", + }, + -1731: { + "text": "Modbus/RIO device timeout", + "description": "A MODBUS device or RIO board is not responding or is not responding within the specified timeout period.", + }, + -1732: { + "text": "Modbus/RIO disable requested", + "description": 'A MODBUS device or RIO board connection has closed because of a user request. For example, you have set the parameter "Remote IO module enable" (DataID 554) to zero.', + }, + -1740: { + "text": "Servo latency too large", + "description": "The master controller cannot connect with a slave controller because the measured communications latency is too large or is negative. Typically there should be no more than 80 microseconds of latency between the nodes. Verify that both controllers are on the same LAN and external traffic is low. Verify that your Ethernet switch is operating properly. Verify that the servo network protocols for all nodes are compatible.", + }, + -1750: {"text": "EtherCAT error", "description": ""}, + -1751: {"text": "EtherCAT slave not responding", "description": ""}, + -1752: {"text": "EtherCAT synchronous message timeout", "description": ""}, + -1753: {"text": "EtherCAT slave not ready", "description": ""}, + -1754: {"text": "EtherCAT disabled", "description": ""}, + -2101: { + "text": "Vertical search limit violated", + "description": "This error is generated during the vertical motion to search for the height of the nest. This indicates that the motion search distance limit was encountered before the specified force level was achieved.", + }, + -2102: { + "text": "Horizontal search limit violated", + "description": "This error is generated during a horizontal motion to search for the edges of the nest. This indicates that the motion search distance limit was encountered before the specified force level was achieved.", + }, + -2103: { + "text": "Insufficient number of Tool X samples", + "description": "The nest height detection routines did not collect the minimum required number of force samples either when the plate was in free air or when the plate was pressing against the nest.", + }, + -2104: { + "text": "Insufficient number of Tool Tx torque samples", + "description": "The nest orientation detection did not collect the minimum required number of torque samples either when the plate was rotated between the edges of the nest.", + }, + -2105: { + "text": "No free air during Yaw detection", + "description": "When using Tx to determine the nest Yaw angle, the line fit to the first samples overlapped with the line fit to the last of the samples. This indicates that no free air region was detected.", + }, + -2106: { + "text": "Motor exceeded peak torque", + "description": "One or more motors was generating the peak permitted torque. The torque will be saturated and the Cartesian gripper forces and torques will not be accurate. The offending motor numbers are listed.", + }, + -2107: { + "text": "GPL 3.2 or later required", + "description": "This package relies on features contained in GPL 3.2 or later.", + }, + -2850: { + "text": "Invalid Gripper Type", + "description": "This command requires a servoed gripper and none is detected.", + }, + -2851: { + "text": "Invalid Station ID", + "description": "A station ID less than 1 or greater than the maximum number of stations has been specified.", + }, + -2852: { + "text": "Invalid robot state to execute command", + "description": "The command cannot be executed while the robot is in its current state. For example, you cannot issue a TeachPlate command while the robot is moving.", + }, + -2853: { + "text": "Rail not at correct station", + "description": "The optional rail is not currently at or moving toward the destination station for this command. Issue a MoveRail command to move the rail to the desired station.", + }, + -2854: { + "text": "Invalid Station type", + "description": "The robot cannot move to a station of the requested type. For example, the PP100 robot cannot move to a horizontal station.", + }, + -2855: { + "text": "No gripper close sensor", + "description": "The command requires a gripper-close sensor but no such sensor exists for the current robot type.", + }, + -3000: {"text": "NULL pointer detected", "description": ""}, + -3001: {"text": "Too many arguments", "description": ""}, + -3002: {"text": "Too few arguments", "description": ""}, + -3003: {"text": "Illegal value", "description": ""}, + -3004: {"text": "Servo not initialized", "description": ""}, + -3005: {"text": "Servo mode transition failed", "description": ""}, + -3006: {"text": "Servo mode locked", "description": ""}, + -3007: {"text": "Servo hash table not found", "description": ""}, + -3008: {"text": "Servo hash entry collision", "description": ""}, + -3009: {"text": "No hash entry found", "description": ""}, + -3010: {"text": "Servo hash table full", "description": ""}, + -3011: {"text": "Illegal parameter access", "description": ""}, + -3012: { + "text": "One or more servo tasks stopped", + "description": "A software watchdog timer has not been updated in the required time. This normally indicates a controller hardware failure or a system software bug.", + }, + -3013: {"text": "Servo task submission failed", "description": ""}, + -3014: { + "text": "Cal parameters not set correctly", + "description": """This error is generated if a homing operation is initiated and one or more parameters that affect homing are not set properly. For example, this error is generated in the following circumstances: + + The "Hardstop envelope limit, mcnt" (DataID 10122) is less thanor equal to zero or it is greater than either the "Soft envelope error limit, mcnt" (DataID 10302) or the "Hard envelope error limit, mcnt" (DataID 10303). If you are not using DataID 10122 for your homing method, set it to a small non-zero value to avoid this error.""", + }, + -3015: { + "text": "Cal position not ready", + "description": """If your robot is equipped with the Precise Absolute encoders, e.g. you have a PrecisePlace 2300/2400 robot, this indicates that the robot does not have its factory encoder calibration data defined. Most likely, one of the "Index code for calibration" (DataID 16241) values is zero. To correct the problem, run the factory encoder calibration program. + + For 3rd party absolute encoders, this error indicates that the full precision absolute encoder position could not be read from the encoder during the homing operation. In some cases, this indicates an error in reading the multiple turn counter for the encoder. If this problem persists, it probably indicates an encoder or controller hardware failure. + + For incremental encoders, this error indicates that a signal required for the selected homing method was not found (e.g. a missing homing or limit or index signal) or a signal was corrupted (e.g. incorrect index due to excessive skew).""", + }, + -3016: { + "text": "Illegal cal seek command", + "description": "This error should never be generated. It indicates that the homing operation sent an illegal command to the servo code. Please report this message along with the process that generated this problem to Precise.", + }, + -3017: { + "text": "No axis selected", + "description": "A debug control panel operation has been requested for an axis that does not exist on a particular servo network node. Reselect the axis and try again. Verify that the node mapped to the axis is active on the network.", + }, + -3100: { + "text": "Hard envelope error", + "description": """This error is generated if the value of the "Position tracking error, mcnt" (DataID 12320) for an axis exceeds its "Hard envelope error limit, mcnt" (DataID 10303) for a sufficient period of time. This error indicates that there was a significant difference between an axis' commanded position and its actual position. This can occur if: + + The axis is being driven too fast for the load that it is carrying. + The axis hits an obstacle and cannot advance. + The axis is oscillating due to a servo tuning instability. + There is a hardware failure of some type. + Normally, this error can be avoided by reducing the speed and/or acceleration of a motion.""", + }, + -3101: { + "text": "PID output saturated too long", + "description": """This error indicates that the sum of the servo feedback terms ("Compensator output torque" (DataID 12304) minus the sum of the "Dynamic feedforward torque" (DataID 12337) and the "Filtered feedforward torque" (DataID 12331)) has either saturated the maximum specified torque for an axis or the "Max positive/negative torque limit for PID feedback" (10351,10352) for more than the time specified the "PID output saturation duration limit" (DataID 10369), which is set to 200 msec by default. + + This error is generated if the limits are set too low or the axis has been over-driven or the axis has collided with an obstacle or some other unexpected error has occurred. + + This check reduces the time that an axis is over-driven or that it drives into an obstacle.""", + }, + -3102: { + "text": "Illegal zero index", + "description": """An encoder zero index pulse was detected when none is expected. The axis is marked as "not calibrated" and the robot must be re-homed. Verify that parameters "Encoder counts for resolution calc, ecnt" (DataID 10203) and "Encoder revs for resolution calc, rev" (DataID 10204) are correct. If the unexpected pulse is due to noise, the parameter "Index noise spikes limit" (DataID 10222) may be adjusted. If the unexpected pulse is due to encoder slippage, parameter "Index skew count limit, mcnt" (DataID 10221) may be adjusted.""", + }, + -3103: { + "text": "Missing zero index", + "description": """No encoder zero index pulse was detected when one is expected. The axis is marked as "not calibrated" and the robot must be re-homed. Verify that parameters "Encoder counts for resolution calc, ecnt" (DataID 10203) and "Encoder revs for resolution calc, rev" (DataID 10204) are correct. If the missing pulse is due to encoder slippage, parameter "Index skew count limit, mcnt" (DataID 10221) may be adjusted.""", + }, + -3104: { + "text": "Motor duty cycle exceeded", + "description": """Duty cycle testing is intended to prevent a motor from being damaged due to overheating. The overheating estimate is computed based upon the average power that is supplied to a motor by an amplifier over a period of time. + + The duty cycle criteria is defined by the "RMS rated motor current, A(rms)" (DataID 10611), "Duty cycle limit in terms of rated torque" (DataID 10623), "Duty cycle exceeded duration" (DataID 10622) and "Duty cycle SPR filter pole" (DataID 10621). + + If the dynamically computed "Duty cycle value, tcnt^2" (DataID 12606) exceeds the "Duty cycle limit, tcnt^2" (DataID 10624), which is defined from the criteria above, the "Motor duty cycle exceeded" error is generated and motor power is disabled. + + If this error is generated, but the motor is still very cool, try changing the "Duty cycle SPR filter pole" (DataID 10621) to average the power over a longer period of time. This will reduce the effect of short periods of high power utilization.""", + }, + -3105: { + "text": "Motor stalled", + "description": """This error indicates that the torque/current for a motor has been saturated at the peak value as defined by the "RMS rated motor current, A(rms)" (DataID 10611) * "AUTO mode motor PEAK(non-RMS)/(RMS rated) current, %" (DataID 10613) for "Motor stalled check duration" (DataID 10617) seconds. + + This is different than the conventional definition of having a motor not moving for a period of time with the torque/current continuously above a specified level.""", + }, + -3106: { + "text": "Axis over-speed", + "description": """This error is generated when power is enabled or during normal running if the system detects that an axis has violated a speed limit. + + If this occurs when power is enabled, it indicates that the axis has violated the speed limit defined by "Special power-up speed limit" (DataID 10210). The possible causes for this error are as follows: + + The motor torque sign is negated and the axis is attempting to run away at high speed. + For 3rd party amplifiers, there is an excessive DAC offset. + For gravity loaded axes, the axis might be dropping very quickly after the brake is released. + A somewhat large offset between the amplifier/motor phase currents might be causing the axis to move excessively when the system attempts to automatically compensate for the phase offset. The "Disable auto phase offset adjustment" (DataID 10695) can be used to turn off this adjustment if desired. + The "Special power-up speed limit" (DataID 10210) may be set too low. + If this error occurs when the system is running, it indicates that either the "Run-time speed limit" (DataID 10208) or the "Manual mode speed limit" (DataID 10209) has been violated. When this runtime error occurs, do the following: + + Reduce the speed of your motions to avoid damaging the motor or its gear train or violating manual control safety regulations. + Review the values of 10208 and 10209 and ensure that they are set properly. + If possible, reduce the gear ratio of the motor to reduce the maximum motor rotational speed. + If the error is triggered by intermittent noise in the velocity signal, reduce the "Motor velocity SPR filter pole" (10207). This value will not affect the PID loop tuning, but it does affect other functions of the system. Review the documentation for 10207 before you change its value.""", + }, + -3107: { + "text": "Amplifier over-current", + "description": "This error is generated if the FPGA firmware detects that the output motor current has exceeded the specified current limits for too long a time.", + }, + -3108: { + "text": "Amplifier over-voltage", + "description": """This error is generated by the FPGA firmware when it detects that the DC bus voltage is too high. The limit on the bus voltage is a function of the controller model being utilized. For the G1xxxA/B controllers, the maximum voltage is approximately 59.5 VDC. For the G3xxx and G2xxxB/C series controllers, the maximum voltage was approximately 445 VDC, but in May 2014 was adjusted down to 436 VDC. Whenever a motor decelerates, it typically pumps power back into the motor power supply and the voltage will rise above its nominal value. For example, if the nominal bus voltage is 330V, it is not unusual to see the voltage rise into the high 300's when a large motor is decelerating. To monitor the DC bus voltage, see "Raw DC bus voltage, volt" (DataID 12684). If this problem persists, it may indicate that the motor power supply must be changed or augmented to include more capacity to dump or absorb more power when the robot is decelerating.""", + }, + -3109: { + "text": "Amplifier under-voltage", + "description": """This indicates that the DC motor bus has dropped too low. This can occur if: + + The bus voltage does not rise above 10V the first time that motor power is enabled. + The bus voltage falls too far (typically 30%) below its nominal value at any time after power has been enabled. + The bus voltage falls below 10V while motor power and the motor amplifiers are enabled. + If this error is generated at the same time as a "Hard E-STOP" error (-1028), it is possible that the "E-stop delay" (DataID 267) is set too short and the DC motor bus voltage is dropping before the amplifiers are disabled. + + If this error persists, it could be due to a fuse on the Motor Power Supply being blown. To monitor the DC bus voltage, see "Raw DC bus voltage, volt" (DataID 12684). To see the current setting of the nominal voltage look at "Nominal DC bus voltage, volt" (DataID 12683).""", + }, + -3110: { + "text": "Amplifier fault", + "description": """This is a generic message indicating that the amplifier hardware has detected a significant problem and has shut down. For example, the output motor current has exceeded the rated limits of the hardware, or the input power to the amplifier has failed while the amplifier was enabled. Frequently, a separate amplifier-related error message is also displayed that provides details about the specific problem. + + Verify that the amplifiers are configured properly. + Verify the motors are wired correctly. + Verify the current loop tuning is correct. Unstable current loop tuning can trigger an amplifier fault. + Verify the motor and motor harness are not shorted. This can be done by disconnecting the motor and measuring the resistance between the UVW phases. The resistance should be the same for each pair and low, but not zero. + If this error occurs when an E-STOP is asserted, verify that the "Delay after setting brakes" (DataID 260) is longer than or equal to the "E-Stop delay" (DataID 267). Normally, DataID 260 should be at least 0.1 seconds shorter than DataID 267.""", + }, + -3111: { + "text": "Brake fault", + "description": "This error indicates that the FPGA has detected a fault condition in the hardware motor brake driver circuit. This test is not currently enabled, so this error message should never be generated.", + }, + -3112: { + "text": "Excessive dual encoder slippage", + "description": """If an axis has been configured for dual encoder loop control (i.e. two encoders are used to control a single motor), this error is generated if there is an excessive position or speed differential between the readings of the two encoders. The slippage limits are defined by "Dual loop position slippage limit" (DataID 10212) and "Dual loop speed slippage limit" (DataID 10216). + + If the axis is driven by a traction drive, some amount of slippage occurs every time that the axis is accelerated or decelerated. This normal slippage is automatically corrected for by the system software. If excessive speed slippage occurs, it could indicate that one of the two encoders has failed and the system was shutdown to prevent a run-away condition.""", + }, + -3113: { + "text": "Motor commutation setup failed", + "description": """The procedure for determining the commutation reference angle for the motor failed and the reference angle was not established. This error normally occurs the first time that motor power is enabled after the controller is restarted or during the homing process. + + The followings are the common causes for the failure: + + Motor power was disabled. The motor power was manually disabled or was automatically disabled due to another error occurring before the end of the search process. + Lose motor cable. Some commutation reference search processes move the motor a short distance. During this small motion, the motor cable became disconnected. Encoder feedback lost. + TEhnec ofdeeedback device (encoder) is not working properly or the "Encoder type" (DataID 10027) was incorrectly set. + Incorrect configuration parameters. Any of the following parameters may have been incorrect set. The Precise Configuration Utility (PCU) contains tools that can be used to verify the correct settings. + DataID 10108, Dedicated DIN's selection + DataID 10650, Commutation sign + DataID 10651, # of pole pairs per motor revolution + DataID 10652, Commutation counts per electrical cycle + Poor current loop tuning. The motor current loop may not be properly tuned. + Improper configuration parameters for selected commutation search method. While the default parameter values will work for a wide variety of axis configurations, there are cases which require parameter adjustment in order to properly perform commutation reference finding. Refer to the selected commutation method parameter description for details. + If the problem persists after checking the above items, please contact Precise support for further assistance.""", + }, + -3114: { + "text": "Servo tasks overrun", + "description": 'The servo tasks are not able to complete their servo computations within the specified servo period. Too many axes are being servoed by a single board, the parameter "Servo update period in sec" (DataID 603) is too small, or a CPU failure has occurred. This error is fatal and prevents robot power from being turned on until the controller is rebooted.', + }, + -3115: { + "text": "Encoder quadrature error", + "description": """For incremental encoders, the encoder count and direction of change is derived from two square waves (channels A & B) that are 90 degrees out of phase. If at any time, the FPGA detects that the phase angle between the channels is too small, a quadrature error is generated. Normally, this error is caused by noise in the encoder lines. To correct this problem, please see the recommendations on wiring encoders and motors in the Installation Section of the Controller Hardware Manual. The motor wiring is as important as the encoder wiring since the motors are often generating the noise that is cause the erroneous reading on the encoder channels. + + When this error occurs, the motor must be commutated again and should be re-homed since this error indicates that the position of the motor/encoder is not longer exactly known. + + For robots with gravity loaded axes (e.g. Z-axes), the "Severe error power off mode" (DataID 142) should be set to mode 1. If the robot's incremental encoders suffer a quadrature error, the robot's brakes will be set immediately and minimize dropping due to gravity loading.""", + }, + -3116: {"text": "Precise encoder index error", "description": ""}, + -3117: { + "text": "Amplifier RMS current exceeded", + "description": """This error indicates that the rated RMS current for an integrated amplifier has been exceeded and the software has disabled motor power to protect the amplifier from being damaged. This is an internal test that is automatically performed and cannot be disabled. See the specifications for your controller for the RMS rating of the amplifiers. If this error occurs, consider doing the following: + + Reduce the accelerations, decelerations and speeds for your motions. + Verify that the rated RMS current for the motor is equal to or below that of the amplifier. If the motor needs to operate at a higher average current level (due to gravity loading, constantly working against friction, etc.), consider purchasing a controller with amplifiers that have a higher rated RMS current. + This error is different than a "Motor duty cycle exceeded" (-3104) error. The duty cycle testing is intended to protect a motor from being damaged due to over-heating. There are a number of parameters for configuring the duty cycle testing to match a motor's operating specifications.""", + }, + -3118: { + "text": "Dedicated DINs not config'ed for Hall", + "description": """If the "Commutation reference setup config" (DataID 10700) specifies that the "Hall-effect" method is to be used for commutating a motor, then the "Dedicated DIN's selection" (DataID 10108) must be set to configure the single-ended inputs in the corresponding encoder connector for use as hall sensor inputs. If DataID 10108 is not set properly, this error is generated. Normally, DataID 10108 is automatically configured if DataID 10700 specifies the "Hall-effect" method.""", + }, + -3119: { + "text": "Illegal 6-step number", + "description": """If the "Commutation reference setup config" (DataID 10700) specifies that the "Hall-effect" method is to be used for commutating a motor, the single-ended inputs in the corresponding encoder connector are read to determine the hall sensor 6-step value. The only permitted hall readings are values from 1 to 6. If the single-ended digital inputs are set to some other value, this error is generated.""", + }, + -3120: { + "text": "Illegal commutation angle", + "description": """This is a general error message that indicates a problem has been detected with the commutation reference angle. Some possible problems that would generate this error message include: + + The "Commutation counts per electrical cycle" (DataID 10652) may be set to zero or some other invalid number. + For a motor with an absolute encoder, the "Commutation offset" (DataID 10775) may be un-initialized so the commutation reference angle cannot be set based upon the encoders single turn data reading. + For a motor with a serial incremental encoder that outputs hall sensor readings during startup, the hall sensor reading may have been unavailable or invalid. + For a motor with hall sensors, the commutation angle specified for a hall sensor reading (DataIDs 10744-10756) may be un-initialized. + For a motor with an analog encoder, the "Analog hall commutation phase angle" (DataID 10756) may not yield a valid commutation reference angle.""", + }, + -3121: { + "text": "Encoder fault", + "description": """This code is generated when an error occurs in communication with a serial encoder such as a Panasonic or Yaskawa serial encoder. This error indicates one of several possible problems. + + For the Yaskawa Sigma II/III serial absolute encoder, this error is generated when the encoder signals a "Runtime Error" due to an error in the encoder's memory. When this occurs, bit 1 in the "Encoder alarm" field (DataID 12251) is set. Check the encoder cable and cycle the power to see if the error goes away. See the Yaskawa encoder alarm documentation for more details. + For the Panasonic serial incremental encoder (type 41), this error is generated when the encoder signals a Preload error after the encoder has been initialized. When this occurs, bit 7 is set in the "Encoder alarm" field (DataID 12251). See the Panasonic encoder alarm documentation for more details. + + This is a severe error and requires the encoder to be re-initialization and/or power cycled.""", + }, + -3122: { + "text": "Soft envelope error", + "description": """The position error of an axis has exceeded the value set in the "Soft envelope error limit" (DataID 10302). This is a safety precaution to ensure that each axis does not deviate too far from its intended position. This error may indicate that the following has occurred: + + An axis has been commanded to accelerate too quickly or move too fast and does not have the power to perform the operation. If necessary, this can be confirmed by datalogging the "Compensator output torque" (DataID 12304) and the "Position tracking error" (DataID 12320) for the axis in question. If this is the source of the envelope error, the position error will increase significantly when the motor torque saturates at its maximum value for several tens of milliseconds. + The axis has gone unstable due to a hardware failure or some other error. This can be confirmed by listening for an audible noise or by datalogging the "Velocity tracking error" (DataID 12321) for the axis in question and looking for high frequency oscillations. + An axis may have crashed into an obstacle and is unable to move to the specified position. + The "Software envelope error limit" (DataID 10302) may be set to too small of a value.""", + }, + -3123: { + "text": "Cannot switch serial encoder mode", + "description": "When an absolute or incremental serial encoder is employed, it can operate in either sync or async mode. In order to switch between the two modes the axis motor power must be turned OFF. If it's not then this error is issued.", + }, + -3124: { + "text": "Serial encoder busy", + "description": """During communication with a serial absolute or incremental encoder, if the encoder takes too long to process a command, this error is issued. When the encoder is in its normal synchronous mode, this error can be generated when the encoder is periodically returning position data. During asynchronous mode , this error can be generated when the host controller issues a specific encoder command to perform a diagnostic function. + + If this error persists, please check the encoder connections and power cycle the encoder.""", + }, + -3125: { + "text": "Illegal encoder command", + "description": "The serial command issued from the controller is not supported by the encoder. Normally, this error should never occur. However, if it is generated, it indicates that there is an internal implementation error. Please inform Precise.", + }, + -3126: { + "text": "Encoder operation error", + "description": """This indicates that a serial encoder is rotating at too high a speed before motor power is turned on or an error has been detected in the encoder position data. This error is generated when the encoder signals the following alarms conditions: + + For Panasonic and Tamagawa serial encoders, this error is generated when "Over-speed" (bit 0) and/or "Counter Error" (bit 2) is set in the "Encoder alarm" field (DataID 12251). + For Yaskawa serial encoders, this error is generated when "Absolute Error" (bit 3), "Over-speed" (bit 4) and/or "Reset Complete" (bit 6) is set in the "Encoder alarm" field (DataID 12251). + This is a standard error and requires the encoder to be re-initialization and/or power cycled. + + See the Panasonic, Tamagawa and Yaskawa encoder alarm documentation for more details.""", + }, + -3127: { + "text": "Encoder battery low", + "description": "Many serial absolute encoders require a connection to an external battery. If the battery voltage is too low, this error is issued. When this error is generated, the encoder is still operational and its internal data, e.g. multi-turn value, will still be valid. However, the battery should be replaced as soon as possible before the internal data is lost.", + }, + -3128: { + "text": "Encoder battery down", + "description": """Many serial absolute encoders require a connection to an external battery. If the battery is dead or this backup power is disconnected (even momentarily), this error is issued and will be latched until cleared. When this error occurs, the encoder's internal multi-turn data is no longer valid and the encoder position must be re-calibrated. The battery must be replaced or reconnected and the factory setup for the encoder must be executed again. + + If this error occurs, verify that the battery voltage is adequate (typically 3.6V or above) and is connected to the encoder via the controller. Once the battery power has been restored, execute the factory calibration program to clear this latched error and reset the encoder's multiple turn counter.""", + }, + -3129: { + "text": "Invalid encoder multi-turn data", + "description": """During run-time, this error is triggered when an encoder's multi-turn counter either over-flows or under-flows or the encoder itself detects a read error of the multi-turn data. + + The over-flow or under-flow error should not occur so long as the encoder is not continuously turned in a single direction and the homing setup is done properly. + + If the error is generated when the controller is restarted or during homing, the error is most likely due to a read error and the encoder should be power cycled to clear this condition. + + For Panasonic and Tamagawa serial absolute encoders, this error is generated when the "Multi-turn Counter Overflow" bit (bit 3) and/or "Multi-turn read error" bit (bit 5) is set in the "Encoder alarm" field (DataID 12251). See the Panasonic/Tamagawa encoder alarm documentation for more details. + + This is a standard error and requires the encoder to be re-initialization and/or power cycled""", + }, + -3130: { + "text": "Illegal encoder operation mode", + "description": """During normal run-time operation, serial absolute and serial incremental encoders should be in synchronous communication mode. If an encoder is not in this mode when motor power is enabled, this error is issued. Normally, this error should not be generated. + + If this error persists, use the Absolute Encoder Diagnostics page in the web interface to re-initialize the serial encoder or power cycle the encoder.""", + }, + -3131: { + "text": "Encoder not supported or mis-matched", + "description": """The parameter "Encoder type" (DataID 10027) is set to a value not supported by the servos or that is inconsistent with the ID code returned by a serial encoder. This can occur if: + + The Encode type is not set to the proper value + When the controller first communicates with the encoder, the encoder communication is corrupted and the wrong encoder ID is received + For bus line absolute encoders, one or more of the encoders did not properly boot and the encoder message received by the controller did not include the response from all of the expected encoders + + This error is fatal and prevents robot power from being turned on until the controller is rebooted.""", + }, + -3132: { + "text": "Trajectory extrapolation limit exceeded", + "description": """This error occurs in systems with slave controllers or GSB boards that are networked to a master controller via Ethernet or RS485. If a set point transmission is lost, the servo code on the slave controller or GSB extrapolates from the previous trajectory set points. If the number of sequential missed set points exceeds the "Number of consecutive extrapolations allowed" (DataID 10424), the servo generates this severe level error and disables motor power. + + If this error occurs, verify that the value of DataID 10424 is not set too low. Typically, this value should be set to 8. + + If this error is generated by an Ethernet slave controller, there is probably noise in the Ethernet network. Ensure that shielded twisted pair Ethernet cables are used to interconnect the master and slave controllers and in any other part of the network that could inject noise. + + If this error is generated by a GSB slave, there is probably noise on the RS485 bus. Verify that the termination jumpers are installed correctly and that the RS485 connectors are firmly seated on all boards, and ensure that shielded twisted pair wire is used for all of the RS485 signals. View the GSB error statistics to identify what GSB board is experiencing noise. + + To minimize noise generation in a custom high voltage robot mechanism, verify that the recommended ferrite beads are installed on all motor power wires and that the communication cables are not routed next to the high voltage signal lines. + + If this error is generated by servos on the master controller, or in systems that are not part of a servo network, it indicates that the controller's CPU is overloaded. If the problem persists, please contact Precise technical support.""", + }, + -3133: { + "text": "Amplifier fault, DC bus stuck", + "description": "This error is generated if an amplifier is in a fault state and the user tries to re-enable motor power while the DC bus voltage is still not below 18VDC. If an amplifier fault has occurred, the high power relay to the motor power supply must be disengaged before motor power is re-enabled.", + }, + -3134: { + "text": "Encoder data or accel/decel limit error", + "description": """This error indicates that either invalid position data has been received from a serial encoder or a serial encoder's position reading changed by too large of a value in a very short period of time. Most often, this means that six or more consecutive "Absolute encoder bad readings" (DataID 12269) or "Absolute encoder communication errors" (DataID 12259) have occurred. If the controller detects a single error of these types, it will usually automatically correct the error and continue normal operation. + + When this error code is generated, the robot must be homed again to re-sample the encoder's full absolute position information. Also, the encoder may be disabled. To restart the encoder's operation, use the "Reset" function in the "Absolute Encoder Diagnostics" page of the web interface. + + If the mechanism is expected to operate at very high accelerations and decelerations, increase the limit used to detect bad encoder readings by reducing the value defined by the "Min. accel time to 5000 RPM, msec" DataID 10252. + + If DataID 10252 is properly set for the expected operation of the axis and the axis is not moving exceeding fast and this problem persists, it typically indicates that there is a hardware problem. The possible hardware defects (starting with the most likely cause) are: a poor connection in an encoder cable (please ensure all contacts are high compression with gold plating); a damaged encoder cable; electronic noise in the encoder cable; a defective encoder; a defective controller.""", + }, + -3135: { + "text": "Phase offset too large", + "description": "The detected amplifier phase offset is too large to be corrected automatically. To perform phase offset correction manually, disable the automatic phase offset adjustment using DataID 10695.", + }, + -3136: { + "text": "Excessive movement during phase offset adjustment", + "description": "Automatic amplifier phase offset correction (DataID 10695) can only be performed on Precise integrated amplifiers and when the motor is not in motion. If this error continues to persist even when the motor is stopped and you have Precise amplifiers, disable this adjustment by setting DataID 10695 to 1 and contact Precise support.", + }, + -3137: { + "text": "Amplifier hardware failure or invalid configuration", + "description": """This error will be reported in the following situations: + + Amplifiers are being enabled and the controller does not have any integrated amplifiers but a Precise amplifier has been specified in the configuration database. + The motor DC bus voltage is too high or low for the configured Precise amplifiers. Most likely, this indicates a software configuration error.""", + }, + -3138: { + "text": "Encoder position not ready", + "description": """The accuracy of the absolute encoder position data or the single turn position data of a serial encoder is reduced due to excessive rotational speed when the controller is turned on. + + For Panasonic and Tamagawa serial encoders, this error is generated when the "Reduced encoder resolution" (bit 1) is set in the "Encoder alarm" field (DataID 12251). See the Panasonic/Tamagawa encoder alarm documentation for more details. + + This alarm bit will be reset automatically by the encoder. However it is sometimes necessary to perform a re-initialization to clear the alarm condition.""", + }, + -3139: { + "text": "Encoder not ready", + "description": """A serial encoder is not ready to operate. This error is generated in the following situations: + + The configuration parameter specifies a serial encoder but the encoder is not physically connected to the controller. + The servo code failed to initialize the serial encoder. + The servo detects the serial encoder model (Panasonic and Tamagawa only), but the model is different from the specified "Encoder type" (DataID 10027). + An attempt was made to enable motor power after a serial encoder communication error (-3140) occurred without re-initialize the encoder. + If a serial encoder is successfully initialized and is operated normally, the "Serial encoder ready" (bit 2) of "Encoder software status word" (DataID 12200) will be set to 1.""", + }, + -3140: { + "text": "Encoder communication error", + "description": """The controller hardware has failed to establish communication with a serial encoder or communication was lost after the encoder was initialized. This typically indicates that the FPGA firmware has timed out while waiting for a communication packet from the encoder. The servo code may also issue an "Encoder not ready" error (-3139) depending upon the failure situation. + + In addition to issuing this error, the "Serial encoder communication error" bit (bit 26) of the "Encoder software status word" (DataID 12200) will be set to 1. If the communication error is detected during the normal synchronized operation, the "Serial encoder ready" bit (bit 2) of the status word will be set to 0. + + This is a severe error and requires the encoder to be re-initialization and/or power cycled.""", + }, + -3141: { + "text": "Encoder overheated", + "description": """A serial encoder has overheated. This error is generated in the following situations. + + For a Yaskawa serial encoder, this error is generated when the "Overheat" bit (bit 5) is set in the "Encoder alarm" field (DataID 12251). See the Yaskawa encoder alarm documentation for more details. + + This is a severe error and requires the encoder to be re-initialization and/or power cycled.""", + }, + -3142: { + "text": "Encoder hall sensor error", + "description": """A serial incremental encoder has detected an error in its hall effect data. + + For a Panasonic serial incremental encoder (type 41), this error is generated when the "Count error between phase" bit (bit 4) and/or the "Illegal Hall Data bit (bit 6) is set in the "Encoder alarm" field (DataID 12251). + This is a severe error and requires the encoder to be re-initialization and/or power cycled.""", + }, + -3143: { + "text": "General serial bus encoder error", + "description": """This error is generated by some serial encoders that pass back a limited amount of status information while the robot is running. When this error occurs, it means that an encoder has signaled an error, but no specific information about the nature of the error is known. + + It may be possible to obtain more information on the error if you cycle the AC power on the controller. For example, if this error was due to a low battery voltage condition, cycling AC power and re-homing the robot will generate an "Encoder battery low" (-3127) error. + + To reset this error, go to the Absolute Encoder maintenance panel in the Settings section of the controller's web interface. If the encoder error persists after the encoder is reset, the Operator Control Panel will display the detailed error information. + + + Currently, this error only occurs with the daisy-chained serial bus Panasonic encoders that are utilized in the Denso line of robots.""", + }, + -3144: { + "text": "Amplifier overheating", + "description": 'The indicated power amplifier has exceeded its permitted operating temperature. Normally this error is followed by the generic error -1610 "Controller overheating". For amplifiers of the G3xxx or G1xxxA/B controllers (except for the G3x3xA 30A version), the limit is 80C. For the amplifiers of the G3x3xA 30A version, the limit is 100C. See Amplifier temperature (DataID 12605) for the actual amplifier temperatures. The G2xxx controller power amplifier chips are equipped with internal temperature monitoring to protect the power modules, but they do not have temperature sensors that can be read.', + }, + -3145: { + "text": "Motor overheating", + "description": 'The indicated motor has exceeded its permitted operating temperature. Normally this error is followed by the generic error -1610 "Controller overheating". The parameter "Max motor temperature" (DataID 10110) determines the maximum allowed temperature. See "Motor temperature" (DataID 12110) for the actual motor temperature. Motor temperature monitoring is configurable and requires special temperature sensors in the motors and special signal conditioning electronics.', + }, + -3146: { + "text": "Earlier encoder error inhibiting power", + "description": "An attempt to enable motor power failed because a severe encoder error previously occurred. Consequently, the encoder is not operational and permitting motor power to be enabled could result in the motor being unstable. Typically, the encoder in question is a serial absolute or incremental type and is either not communicating properly or must be reset. Please go to the following web page to view the serial encoder status and to clear any error conditions: Setup > Hardware Tuning and Diagnostics > Absolute Encoder.", + }, + -3147: { + "text": "Abnormal envelope error", + "description": """This error indicates a more severe instance of the condition signaled by the "Hard envelope error" (-3100). Like the "Hard" error, this error indicates that there was a significant difference betortween an axis' commanded position and its actual position. And, like the "Hard" error, this error is generated when the value of the "Position tracking error, mcnt" (DataID 12320) for an axis exceeds its "Hard envelope error limit, mcnt" (DataID 10303) for a sufficient period of time. + + + However, the "Abnormal" error is generated in place of the "Hard" error when the command velocity is lower than 33% of the Manual mode speed limit (DataID 10209). So, the "Abnormal" error indicates that a significant position error occurred when an axis was not supposed to be moving fast. This error normally indicates a collision occurred when the robot was moving at a slow speed or one of the following motor configuration parameters are incorrect. + + Encoder sign (DataID 10202) + Torque sign (DataID 10609) + Commutation sign (DataID 10650) + Hard envelope error (DataID 10303) + Commutation offset (DataID 10775) + Commutation position at zero index (16653)""", + }, + -3148: { + "text": "Encoder hardware related warning", + "description": "This indicates that an internal encoder warning bit is ON. Currently this warning is only reported by BiSS encoders. Please consult the documentation for the specific encoder model to determine the nature of the error.\n\nThis is only a warning message. This error will not stop program execution or turn Off robot/motor power.", + }, + -3149: { + "text": "Velocity restrict limit exceeded", + "description": """(CAT-3, GPL 4.2 or later) The low-level control that advances the commutation angle for a BLDC motor has detected that the motor is attempting to turn faster than the Run-time or Manual mode speed limits (DataIDs 10208/10209) permit. The motor is immediately shut down and an error is signaled. + + Since a BLDC motor cannot generate torque if the commutation angle is not properly set and cannot rotate unless the commutation angle is properly advanced, this check provides a low level independent test to ensure that a motor is not rotating faster than permitted. This is sometimes referred to as an independent "Velocity Restrict" test. + + This error will only be reported in controllers with FPGA firmware that supports CAT-3 safety capability, and the CAT-3 safety feature is enabled in software. + + This error may indicate that: + + DataIDs 10208/10209 are set too low + An application program is trying to drive the motor faster than allowed + An encoder read error has occurred due to noise on the encoder lines or bad data has been sent by an encoder. For non-CAT-3 systems, these types of errors typically generate "Encoder data or accel/decel limit errors" (-3134) or "Encoder operation errors" (-3126) or "Encoder communication errors" (-3140). + A system hardware failure or a system software error has occurred that attempted to drive the motor faster than allowed.""", + }, + -3150: {"text": "Position compare not enabled", "description": ""}, + -3151: {"text": "Position compare memory allocation failed", "description": ""}, + -3152: {"text": "Position compare buffer empty", "description": ""}, + -3153: {"text": "Failed to start position compare task", "description": ""}, + -3154: {"text": "Position compare buffer full", "description": ""}, + -3155: {"text": "Invalid DOUT for position compare", "description": ""}, + -3156: {"text": "Motor moving away from compare position", "description": ""}, + -3157: {"text": "Position compare internal inconsistence error", "description": ""}, + -3160: { + "text": "Dump circuit duty cycle exceeded", + "description": "The motor power dump circuit has been turn on too long. The dump circuit has been switched off to avoid overheating the dump resistor.\n\nNOTE: If you load GPL versions 4.1J1 and later or 4.2E and later into a PreciseFlex™400 Rev B or earlier robot, this error may be erroneously generated. If this occurs, loading GPL 4.2H or later will properly detect the dump board in the robot and eliminate this error.", + }, + -3161: {"text": "No position updated in Fpga", "description": ""}, + -4000: { + "text": "Cannot connect to vision server", + "description": 'GPL cannot establish an Ethernet TCP connection with the Precise Vision software on the vision host PC. If the web interface is not working, check the basic Ethernet connectivity. In addition, verify that the "Vision Server IP address" (DataID 424) is set properly for your vision host PC. Make sure that Precise Vision is active on that PC.', + }, + -4001: { + "text": "Invalid vision protocol", + "description": "GPL is unable to decode a message received from PreciseVision. Verify that the versions of GPL and PreciseVision are compatible. If so, please report this error to customer service.", + }, + -4002: { + "text": "Vision interlocked", + "description": "The communications link to PreciseVision is being used by a different GPL thread. Only one thread may access vision at a time. Stop any other threads that may be accessing PreciseVision.", + }, + -4003: {"text": "Vision interlocked", "description": ""}, + -4010: { + "text": "Vision invalid protocol", + "description": "PreciseVision is unable to decode a message received from GPL. Verify that the versions of GPL and PreciseVision are compatible. If so, please report this error to customer service.", + }, + -4011: { + "text": "Vision internal error", + "description": "An unexpected error has occurred within PreciseVision. Please report this error to customer service", + }, + -4012: { + "text": "Vision unknown process", + "description": "A GPL vision method has specified a vision process that is not defined in the current PreciseVision project. Verify that the correct GPL project and PreciseVision project are loaded.", + }, + -4013: { + "text": "Vision unknown tool", + "description": "A GPL vision method has specified a vision tool that is not defined in the current PreciseVision project. Verify that the correct GPL project and PreciseVision project are loaded.", + }, + -4014: { + "text": "Vision invalid index", + "description": "A GPL vision Result method has specified an index for a result that does not exist. Verify that the correct GPL project and PreciseVision project are loaded. Verify the number of results returned by the current vision process.", + }, + -4016: { + "text": "Vision invalid arguments", + "description": "The argument values specified in a vision ToolProperty method are not valid for that property.", + }, + -4017: { + "text": "Vision property not found", + "description": "The property name specified in a vision ToolProperty method does not exist. Verify that the correct GPL project and PreciseVision project are loaded.", + }, + -4018: { + "text": "Vision property protected", + "description": "An attempt has been made to change a vision tool property than cannot be modified.", + }, + -4019: { + "text": "Vision process failed", + "description": "Execution of a selected process within PreciseVision has failed. This is normally due to the acquisition operation failing.", + }, + -4020: { + "text": "Vision invalid calibration type", + "description": "A remote request to execute a vision calibration procedure failed because an invalid calibration type was specified. Set the calibration type to a valid type and try again.", + }, + -4021: { + "text": "Vision invalid project name", + "description": "A remote request to load a vision project failed. Probably the project name was not valid. Verify that the name specified is correct and that the file exists in the correct location.", + }, + -4022: { + "text": "Vision calibration file not found", + "description": "A remote request to load a vision calibration file failed because the calibration file could not be found. Verify that the file name specified is correct and that the file exists in the correct location.", + }, + -4023: { + "text": "Vision project not saved", + "description": "A remote request to load a new vision project has failed because the current project has not been saved. Save the current project before attempting to load a new one.", + }, +} diff --git a/pylabrobot/brooks/pf400_test.ipynb b/pylabrobot/brooks/pf400_test.ipynb new file mode 100644 index 00000000000..e061137d6d1 --- /dev/null +++ b/pylabrobot/brooks/pf400_test.ipynb @@ -0,0 +1,397 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# PreciseFlex 400 — Teaching & Freedrive Notebook\n", + "\n", + "Uses the `JointArm` frontend with a `PreciseFlex400Backend`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "\n", + "from pylabrobot.arms.joint_arm import JointArm\n", + "from pylabrobot.brooks.precise_flex import (\n", + " ElbowOrientation,\n", + " PFAxis,\n", + " PreciseFlex400Backend,\n", + " PreciseFlexBackend,\n", + ")\n", + "from pylabrobot.resources import Coordinate, Resource, Rotation\n", + "\n", + "POSITIONS_FILE = Path(\"positions.json\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def save_position(name: str, pos):\n", + " \"\"\"Save a named position to disk.\"\"\"\n", + " positions = json.loads(POSITIONS_FILE.read_text()) if POSITIONS_FILE.exists() else {}\n", + " positions[name] = {\n", + " \"x\": pos.location.x,\n", + " \"y\": pos.location.y,\n", + " \"z\": pos.location.z,\n", + " \"roll\": pos.rotation.x,\n", + " \"pitch\": pos.rotation.y,\n", + " \"yaw\": pos.rotation.z,\n", + " \"orientation\": pos.orientation.value if pos.orientation else None,\n", + " }\n", + " POSITIONS_FILE.write_text(json.dumps(positions, indent=2))\n", + " print(f\"Saved '{name}'\")\n", + "\n", + "\n", + "def load_position(name: str):\n", + " \"\"\"Load a named position from disk.\"\"\"\n", + " positions = json.loads(POSITIONS_FILE.read_text())\n", + " p = positions[name]\n", + " return (\n", + " Coordinate(p[\"x\"], p[\"y\"], p[\"z\"]),\n", + " Rotation(p[\"roll\"], p[\"pitch\"], p[\"yaw\"]),\n", + " ElbowOrientation(p[\"orientation\"]) if p[\"orientation\"] else None,\n", + " )\n", + "\n", + "\n", + "def list_positions():\n", + " \"\"\"List all saved positions.\"\"\"\n", + " if not POSITIONS_FILE.exists():\n", + " print(\"No saved positions.\")\n", + " return\n", + " positions = json.loads(POSITIONS_FILE.read_text())\n", + " for name, p in positions.items():\n", + " print(\n", + " f\" {name}: x={p['x']:.1f} y={p['y']:.1f} z={p['z']:.1f} \"\n", + " f\"yaw={p['yaw']:.1f} {p['orientation'] or ''}\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connect" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "backend = PreciseFlex400Backend(host=\"192.168.0.1\")\n", + "reference = Resource(\"workcell\", size_x=2000, size_y=2000, size_z=0)\n", + "arm = JointArm(backend=backend, reference_resource=reference)\n", + "\n", + "await arm.setup()\n", + "print(f\"Version: {await backend.get_version()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Read current position" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "cart = await arm.get_cartesian_position()\n", + "print(f\"Cartesian: x={cart.location.x:.1f}, y={cart.location.y:.1f}, z={cart.location.z:.1f}\")\n", + "print(\n", + " f\"Rotation: yaw={cart.rotation.z:.1f}, pitch={cart.rotation.y:.1f}, roll={cart.rotation.x:.1f}\"\n", + ")\n", + "print(f\"Elbow: {cart.orientation}\")\n", + "print()\n", + "joints = await arm.get_joint_position()\n", + "print(f\"Joints: {joints}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Freedrive mode\n", + "\n", + "Enter freedrive to manually position the arm. Use `[0]` to free all axes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await backend.start_freedrive_mode([0])\n", + "print(\"Freedrive ON -- move the arm manually\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Read position while in freedrive\n", + "cart = await arm.get_cartesian_position()\n", + "print(\n", + " f\"x={cart.location.x:.1f}, y={cart.location.y:.1f}, z={cart.location.z:.1f}, \"\n", + " f\"yaw={cart.rotation.z:.1f}, orientation={cart.orientation}\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await backend.stop_freedrive_mode()\n", + "print(\"Freedrive OFF\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teach & save positions\n", + "\n", + "Use freedrive to move the arm, then save the position to disk." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Save current position with a name\n", + "pos = await arm.get_cartesian_position()\n", + "save_position(\"home\", pos)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Save another position\n", + "pos = await arm.get_cartesian_position()\n", + "save_position(\"plate_pickup\", pos)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "list_positions()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Replay saved positions" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "loc, rot, orientation = load_position(\"home\")\n", + "await arm.move_to_location(\n", + " location=loc,\n", + " direction=rot,\n", + " backend_params=PreciseFlexBackend.MoveToLocationParams(orientation=orientation, speed=30),\n", + ")\n", + "print(\"Moved to 'home'\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "loc, rot, orientation = load_position(\"plate_pickup\")\n", + "await arm.move_to_location(\n", + " location=loc,\n", + " direction=rot,\n", + " backend_params=PreciseFlexBackend.MoveToLocationParams(orientation=orientation, speed=30),\n", + ")\n", + "print(\"Moved to 'plate_pickup'\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Move with speed control" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await arm.move_to_location(\n", + " location=Coordinate(300, 0, 200),\n", + " direction=Rotation(0, 0, 0),\n", + " backend_params=PreciseFlexBackend.MoveToLocationParams(speed=20),\n", + ")\n", + "print(await arm.get_cartesian_position())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Lefty / Righty\n", + "\n", + "Pass `ElbowOrientation` through `backend_params`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await arm.move_to_location(\n", + " location=Coordinate(300, 0, 200),\n", + " direction=Rotation(0, 0, 0),\n", + " backend_params=PreciseFlexBackend.MoveToLocationParams(\n", + " orientation=ElbowOrientation.RIGHT,\n", + " speed=30,\n", + " ),\n", + ")\n", + "print(\"Righty:\", await arm.get_cartesian_position())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await arm.move_to_location(\n", + " location=Coordinate(300, 0, 200),\n", + " direction=Rotation(0, 0, 0),\n", + " backend_params=PreciseFlexBackend.MoveToLocationParams(\n", + " orientation=ElbowOrientation.LEFT,\n", + " speed=30,\n", + " ),\n", + ")\n", + "print(\"Lefty:\", await arm.get_cartesian_position())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await backend.change_config()\n", + "print(\"Flipped:\", await arm.get_cartesian_position())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Move joints" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await arm.move_to_joint_position(\n", + " {PFAxis.BASE: 45.0},\n", + " backend_params=PreciseFlexBackend.MoveToJointPositionParams(speed=30),\n", + ")\n", + "print(await arm.get_joint_position())" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Gripper" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await arm.open_gripper(gripper_width=80.0)\n", + "print(f\"Gripper closed: {await arm.is_gripper_closed()}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await arm.close_gripper(gripper_width=10.0)\n", + "print(f\"Gripper closed: {await arm.is_gripper_closed()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Disconnect" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await backend.move_to_safe()\n", + "await arm.stop()\n", + "print(\"Done\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/pylabrobot/brooks/precise_flex.py b/pylabrobot/brooks/precise_flex.py new file mode 100644 index 00000000000..f4914e63c95 --- /dev/null +++ b/pylabrobot/brooks/precise_flex.py @@ -0,0 +1,1816 @@ +import asyncio +import logging +import warnings +from abc import ABC +from dataclasses import dataclass +from enum import IntEnum +from typing import Dict, List, Literal, Optional, Union + +from pylabrobot.arms.backend import CanFreedrive, HasJoints, OrientableGripperArmBackend +from pylabrobot.arms.orientable_arm import OrientableArm +from pylabrobot.arms.standard import GripperLocation +from pylabrobot.brooks.error_codes import ERROR_CODES +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.device import Device, Driver +from pylabrobot.io.socket import Socket +from pylabrobot.resources import Coordinate, Rotation +from pylabrobot.resources.resource import Resource + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Supporting types +# --------------------------------------------------------------------------- + + +ElbowOrientation = Literal["right", "left"] + + +class PFAxis(IntEnum): + BASE = 1 + SHOULDER = 2 + ELBOW = 3 + WRIST = 4 + GRIPPER = 5 + RAIL = 6 + + +@dataclass +class PreciseFlexGripperLocation(GripperLocation): + rail: Optional[float] = None + orientation: Optional[ElbowOrientation] = None + + +@dataclass +class VerticalAccess: + """Access location from above (most common pattern for stacks and tube racks). + + Args: + approach_height_mm: Height above the target position to move to before descending to grip + clearance_mm: Vertical distance to retract after gripping before lateral movement + gripper_offset_mm: Additional vertical offset added when holding a plate + """ + + approach_height_mm: float = 100 + clearance_mm: float = 100 + gripper_offset_mm: float = 10 + + +@dataclass +class HorizontalAccess: + """Access location from the side (for hotel-style plate carriers). + + Args: + approach_distance_mm: Horizontal distance in front of the target to stop before moving in + clearance_mm: Horizontal distance to retract after gripping before lifting + lift_height_mm: Vertical distance to lift the plate after horizontal retract + gripper_offset_mm: Additional vertical offset added when holding a plate + """ + + approach_distance_mm: float = 50 + clearance_mm: float = 50 + lift_height_mm: float = 100 + gripper_offset_mm: float = 10 + + +AccessPattern = Union[VerticalAccess, HorizontalAccess] + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + + +class PreciseFlexError(Exception): + def __init__(self, replycode: int, message: str): + self.replycode = replycode + self.message = message + if replycode in ERROR_CODES: + text = ERROR_CODES[replycode]["text"] + description = ERROR_CODES[replycode]["description"] + super().__init__(f"PreciseFlexError {replycode}: {text}. {description} - {message}") + else: + super().__init__(f"PreciseFlexError {replycode}: {message}") + + +# --------------------------------------------------------------------------- +# Driver — owns Socket I/O and device lifecycle +# --------------------------------------------------------------------------- + + +class PreciseFlexDriver(Driver): + """Driver for PreciseFlex robotic arms. + + Owns the Socket I/O connection and device-level operations (power, attach, + home, response mode). Exposes ``send_command`` as the generic wire method. + + Documentation and error codes available at + https://www2.brooksautomation.com/#Root/Welcome.htm + """ + + def __init__(self, host: str, port: int = 10100, timeout: int = 20) -> None: + super().__init__() + self.io = Socket(human_readable_device_name="Precise Flex Arm", host=host, port=port) + self.timeout = timeout + + # -- communication --------------------------------------------------------- + + async def send_command(self, command: str) -> str: + await self.io.write(command.encode("utf-8") + b"\n") + reply = await self.io.readline() + return self._parse_reply_ensure_successful(reply) + + def _parse_reply_ensure_successful(self, reply: bytes) -> str: + """Parse reply from Precise Flex. + + Expected format: b'replycode data message\r\n' + - replycode is an integer at the beginning + - data is rest of the line (excluding CRLF) + """ + text = reply.decode().strip() + if not text: + raise PreciseFlexError(-1, "Empty reply from device.") + parts = text.split(" ", 1) + if len(parts) == 1: + replycode = int(parts[0]) + data = "" + else: + replycode, data = int(parts[0]), parts[1] + if replycode != 0: + raise PreciseFlexError(replycode, data) + return data + + # -- lifecycle ------------------------------------------------------------- + + async def setup(self, skip_home: bool = False): + """Initialize the PreciseFlex driver. + + Opens the socket connection, sets response mode to PC, powers on the + robot, attaches it, and (optionally) homes it. + """ + await self.io.setup() + await self.set_response_mode("pc") + await self.power_on_robot() + await self.attach(1) + if not skip_home: + await self.home() + + async def stop(self): + """Stop the PreciseFlex driver.""" + await self.detach() + await self.power_off_robot() + await self.exit() + await self.io.stop() + + # -- device-level commands ------------------------------------------------- + + async def exit(self) -> None: + """Close the communications link immediately. + + Note: + Does not affect any robots that may be active. + """ + await self.io.write(b"exit\n") + + ResponseMode = Literal["pc", "verbose"] + + async def get_mode(self) -> ResponseMode: + """Get the current response mode. + + Returns: + Current mode (0 = PC mode, 1 = verbose mode) + """ + response = await self.send_command("mode") + mapping: Dict[int, PreciseFlexDriver.ResponseMode] = {0: "pc", 1: "verbose"} + return mapping[int(response)] + + async def set_response_mode(self, mode: ResponseMode) -> None: + """Set the response mode. + + Args: + mode: Response mode to set. + 0 = Select PC mode + 1 = Select verbose mode + + Note: + When using serial communications, the mode change does not take effect + until one additional command has been processed. + """ + if mode not in ["pc", "verbose"]: + raise ValueError("Mode must be 'pc' or 'verbose'") + mapping = {"pc": 0, "verbose": 1} + await self.send_command(f"mode {mapping[mode]}") + + async def power_on_robot(self): + """Power on the robot.""" + error: Optional[PreciseFlexError] = None + for _ in range(3): + try: + await self.set_power(True, self.timeout) + except PreciseFlexError as e: + logger.warning(f"Error powering on robot, retrying... Attempt {_ + 1}/3. Error: {e}") + error = e + else: + return + + if error: + raise error + raise RuntimeError("Failed to power on robot after 3 attempts for unknown reasons.") + + async def power_off_robot(self): + """Power off the robot.""" + await self.set_power(False) + + async def set_power(self, enable: bool, timeout: int = 0) -> None: + """Enable or disable robot high power. + + Args: + enable: True to enable power, False to disable + timeout: Wait timeout for power to come on. + 0 or omitted = do not wait for power to come on + > 0 = wait this many seconds for power to come on + -1 = wait indefinitely for power to come on + + Raises: + PreciseFlexError: If power does not come on within the specified timeout. + """ + power_state = 1 if enable else 0 + if timeout == 0: + await self.send_command(f"hp {power_state}") + else: + await self.send_command(f"hp {power_state} {timeout}") + + async def get_power_state(self) -> int: + """Get the current robot power state. + + Returns: + Current power state (0 = disabled, 1 = enabled) + """ + response = await self.send_command("hp") + return int(response) + + async def attach(self, attach_state: Optional[int] = None) -> int: + """Attach or release the robot, or get attachment state. + + Args: + attach_state: If omitted, returns the attachment state. 0 = release the robot; 1 = attach the robot. + + Returns: + If attach_state is omitted, returns 0 if robot is not attached, -1 if attached. Otherwise returns 0 on success. + + Note: + The robot must be attached to allow motion commands. + """ + if attach_state is None: + response = await self.send_command("attach") + return int(response) + await self.send_command(f"attach {attach_state}") + return 0 + + async def detach(self): + """Detach the robot.""" + await self.attach(0) + + async def home(self) -> None: + """Home the robot associated with this thread. + + Note: + Requires power to be enabled. + Requires robot to be attached. + Waits until the homing is complete. + """ + await self.send_command("home") + + async def home_all(self) -> None: + """Home all robots. + + Note: + Requires power to be enabled. + Requires that robots not be attached. + """ + await self.send_command("homeAll") + + async def _wait_for_eom(self) -> None: + """Wait for the robot to reach the end of the current motion. + + Waits for the robot to reach the end of the current motion or until it is stopped by + some other means. Does not reply until the robot has stopped. + """ + await self.send_command("waitForEom") + await asyncio.sleep(0.2) + + async def state(self) -> str: + """Return state of motion. + + This value indicates the state of the currently executing or last completed robot motion. + For additional information, please see 'Robot.TrajState' in the GPL reference manual. + + Returns: + str: The current motion state. + """ + return await self.send_command("state") + + +# --------------------------------------------------------------------------- +# Arm Backend — protocol translation, capability methods +# --------------------------------------------------------------------------- + + +class PreciseFlexArmBackend(OrientableGripperArmBackend, HasJoints, CanFreedrive, ABC): + """Backend for the PreciseFlex robotic arm. + + Default to using Cartesian coordinates; some methods in Brook's TCS + don't work with Joint coordinates. + + Documentation and error codes available at + https://www2.brooksautomation.com/#Root/Welcome.htm + """ + + def __init__( + self, + driver: PreciseFlexDriver, + is_dual_gripper: bool = False, + has_rail: bool = False, + ) -> None: + super().__init__() + self._driver = driver + self.profile_index: int = 1 + self.location_index: int = 1 + self._rail_position_index = 1 + self.horizontal_compliance: bool = False + self.horizontal_compliance_torque: int = 0 + self._has_rail = has_rail + self._is_dual_gripper = is_dual_gripper + if is_dual_gripper: + warnings.warn( + "Dual gripper support is experimental and may not work as expected.", UserWarning + ) + + # -- coordinate conversion helpers ----------------------------------------- + + def _convert_to_cartesian_array( + self, position: PreciseFlexGripperLocation + ) -> tuple[float, float, float, float, float, float, int]: + """Convert a PreciseFlexGripperLocation object to a list of cartesian coordinates.""" + orientation_int = ( + self._convert_orientation_str_to_int(position.orientation) + if position.orientation is not None + else 0 + ) + return ( + position.location.x, + position.location.y, + position.location.z, + position.rotation.yaw, + position.rotation.pitch, + position.rotation.roll, + orientation_int, + ) + + def _convert_orientation_int_to_str(self, orientation_int: int) -> Optional[ElbowOrientation]: + if orientation_int == 1: + return "right" + if orientation_int == 2: + return "left" + return None + + def _convert_orientation_str_to_int(self, orientation: ElbowOrientation) -> int: + if orientation == "left": + return 2 + if orientation == "right": + return 1 + return 0 + + # -- high-level motion API ------------------------------------------------- + + async def _set_speed(self, speed_percent: float): + """Set the speed percentage of the arm's movement (0-100).""" + await self.set_profile_speed(self.profile_index, speed_percent) + + async def _get_speed(self) -> float: + """Get the current speed percentage of the arm's movement.""" + return await self.get_profile_speed(self.profile_index) + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ): + """Open the gripper to the specified width.""" + await self._set_grip_open_pos(gripper_width) + await self._driver.send_command("gripper 1") + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ): + """Close the gripper to the specified width.""" + await self._set_grip_close_pos(gripper_width) + await self._driver.send_command("gripper 2") + + async def halt(self, backend_params: Optional[BackendParams] = None): + """Stops the current robot immediately but leaves power on.""" + await self._driver.send_command("halt") + + async def move_to_safe(self) -> None: + """Moves the robot to Safe Position. + + Does not include checks for collision with 3rd party obstacles inside the work volume of the robot. + """ + await self._driver.send_command("movetosafe") + + async def approach( + self, + position: Union[PreciseFlexGripperLocation, Dict[int, float]], + access: Optional[AccessPattern] = None, + ): + """Move the arm to an approach position (offset from target). + + Args: + position: Target position (PreciseFlexGripperLocation or Dict[int, float]) + access: Access pattern defining how to approach the target. Defaults to VerticalAccess() if not specified. + + Example: + # Simple vertical approach (default) + await backend.approach(position) + + # Horizontal hotel-style approach + await backend.approach( + position, + HorizontalAccess( + approach_distance_mm=50, + clearance_mm=50, + lift_height_mm=100 + ) + ) + """ + if access is None: + access = VerticalAccess() + if isinstance(position, dict): + await self._approach_j(position, access) + elif isinstance(position, PreciseFlexGripperLocation): + await self._approach_c(position, access) + else: + raise TypeError("Position must be of type Dict[int, float] or PreciseFlexGripperLocation.") + + async def move_rail(self, position: float) -> None: + """Move the rail to the specified position. + + Args: + position: Rail destination in mm. + + Raises: + RuntimeError: If the arm does not have a rail. + """ + if not self._has_rail: + raise RuntimeError("This arm does not have a rail.") + await self._set_rail_position(self._rail_position_index, position) + await self._move_rail(station_id=self._rail_position_index) + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Park the arm to its default safe position.""" + await self.move_to_safe() + + # -- JointArmBackend interface (joint-space) -------------------------------- + + @dataclass + class PickUpParams(BackendParams): + access: Optional[AccessPattern] = None + finger_speed_percent: float = 50.0 + grasp_force: float = 10.0 + orientation: Optional[ElbowOrientation] = None + rail_position: Optional[float] = None + + async def pick_up_at_joint_position( + self, + position: Dict[int, float], + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up at the specified joint position.""" + if not isinstance(backend_params, self.PickUpParams): + backend_params = PreciseFlexArmBackend.PickUpParams() + access = backend_params.access or VerticalAccess() + await self._set_grasp_data( + plate_width=resource_width, + finger_speed_percent=backend_params.finger_speed_percent, + grasp_force=backend_params.grasp_force, + ) + await self._pick_plate_j(position, access) + + @dataclass + class DropParams(BackendParams): + access: Optional[AccessPattern] = None + orientation: Optional[ElbowOrientation] = None + rail_position: Optional[float] = None + + async def drop_at_joint_position( + self, + position: Dict[int, float], + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop at the specified joint position.""" + if not isinstance(backend_params, self.DropParams): + backend_params = PreciseFlexArmBackend.DropParams() + access = backend_params.access or VerticalAccess() + await self._place_plate_j(position, access) + + @dataclass + class MoveToJointPositionParams(BackendParams): + speed: Optional[float] = None + + async def move_to_joint_position( + self, + position: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move the arm to the specified joint position.""" + if not isinstance(backend_params, self.MoveToJointPositionParams): + backend_params = PreciseFlexArmBackend.MoveToJointPositionParams() + if backend_params.speed is not None: + await self._set_speed(backend_params.speed) + current = await self.get_joint_position() + joint_coords = {**current, **position} + await self._move_j(profile_index=self.profile_index, joint_coords=joint_coords) + + async def get_joint_position( + self, backend_params: Optional[BackendParams] = None + ) -> Dict[int, float]: + """Get the current joint position of the arm.""" + await self._driver._wait_for_eom() + num_tries = 2 + for _ in range(num_tries): + data = await self._driver.send_command("wherej") + parts = data.split() + if len(parts) > 0: + break + else: + raise PreciseFlexError(-1, "Unexpected response format from wherej command.") + return self._parse_angles_response(parts) + + async def get_gripper_location( + self, backend_params: Optional[BackendParams] = None + ) -> PreciseFlexGripperLocation: + """Get the current position of the arm in Cartesian space.""" + await self._driver._wait_for_eom() + num_tries = 2 + for _ in range(num_tries): + data = await self._driver.send_command("wherec") + parts = data.split() + if len(parts) == 7: + break + else: + raise PreciseFlexError(-1, "Unexpected response format from wherec command.") + x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts[0:6]) + config = int(parts[6]) + elbow_orientation = self._convert_orientation_int_to_str(config) + rail_position = (await self.get_joint_position())[PFAxis.RAIL] if self._has_rail else None + + return PreciseFlexGripperLocation( + location=Coordinate(x, y, z), + rotation=Rotation(x=roll, y=pitch, z=yaw), + orientation=elbow_orientation, + rail=rail_position, + ) + + # -- OrientableArmBackend interface (Cartesian) ----------------------------- + + async def pick_up_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up at the specified Cartesian location.""" + if not isinstance(backend_params, self.PickUpParams): + backend_params = PreciseFlexArmBackend.PickUpParams() + if backend_params.rail_position is not None: + await self.move_rail(backend_params.rail_position) + elif self._has_rail: + raise ValueError( + "rail_position must be specified for pick_up_at_location when using a rail-equipped arm." + ) + access = backend_params.access or VerticalAccess() + coords = PreciseFlexGripperLocation( + location=location, rotation=Rotation(z=direction), orientation=backend_params.orientation + ) + await self._set_grasp_data( + plate_width=resource_width, + finger_speed_percent=backend_params.finger_speed_percent, + grasp_force=backend_params.grasp_force, + ) + await self._pick_plate_c(cartesian_position=coords, access=access) + + async def drop_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop at the specified Cartesian location.""" + if not isinstance(backend_params, self.DropParams): + backend_params = PreciseFlexArmBackend.DropParams() + if backend_params.rail_position is not None: + await self.move_rail(backend_params.rail_position) + elif self._has_rail: + raise ValueError( + "rail_position must be specified for drop_at_location when using a rail-equipped arm." + ) + access = backend_params.access or VerticalAccess() + coords = PreciseFlexGripperLocation( + location=location, rotation=Rotation(z=direction), orientation=backend_params.orientation + ) + await self._place_plate_c(cartesian_position=coords, access=access) + + @dataclass + class MoveToLocationParams(BackendParams): + speed: Optional[float] = None + orientation: Optional[ElbowOrientation] = None + rail_position: Optional[float] = None + + async def move_to_location( + self, + location: Coordinate, + direction: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move the arm to the specified Cartesian location.""" + if not isinstance(backend_params, self.MoveToLocationParams): + backend_params = PreciseFlexArmBackend.MoveToLocationParams() + if backend_params.speed is not None: + await self._set_speed(backend_params.speed) + + if backend_params.rail_position is not None: + await self.move_rail(backend_params.rail_position) + elif self._has_rail: + raise ValueError( + "Rail position must be specified for move_to_location when using a rail-equipped arm." + ) + + coords = PreciseFlexGripperLocation( + location=location, + rotation=Rotation(x=-180, y=90, z=direction), + orientation=backend_params.orientation, + ) + await self._move_c(profile_index=self.profile_index, cartesian_coords=coords) + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + """(Single Gripper Only) Tests if the gripper is fully closed by checking the end-of-travel sensor. + + Returns: + For standard gripper: True if the gripper is within 2mm of fully closed, otherwise False. + """ + if self._is_dual_gripper: + raise ValueError("IsGripperClosed command is only valid for single gripper robots.") + response = await self._driver.send_command("IsFullyClosed") + return int(response) == -1 + + async def are_grippers_closed(self) -> tuple[bool, bool]: + """(Dual Gripper Only) Tests if each gripper is fully closed by checking the end-of-travel sensors.""" + if not self._is_dual_gripper: + raise ValueError("AreGrippersClosed command is only valid for dual gripper robots.") + response = await self._driver.send_command("IsFullyClosed") + ret_int = int(response) + gripper_1_closed = (ret_int & 1) != 0 + gripper_2_closed = (ret_int & 2) != 0 + return (gripper_1_closed, gripper_2_closed) + + async def start_freedrive_mode( + self, free_axes: Optional[List[int]] = None, backend_params=None + ) -> None: + """Enter freedrive mode, allowing manual movement of the specified joints. + + The robot must be attached to enter free mode. + + Args: + free_axes: List of joint indices to free. Use [0] for all axes. + """ + for axis in free_axes or [ + PFAxis.BASE, + PFAxis.SHOULDER, + PFAxis.ELBOW, + PFAxis.WRIST, + PFAxis.RAIL, + ]: + await self._driver.send_command(f"freemode {axis}") + + async def stop_freedrive_mode(self, backend_params=None) -> None: + """Exit freedrive mode for all axes.""" + await self._driver.send_command("freemode -1") + + # -- internal pick/place helpers ------------------------------------------- + + async def _approach_j(self, joint_position: Dict[int, float], access: AccessPattern): + """Move the arm to a position above the specified coordinates. + + The approach behavior depends on the access pattern: + - VerticalAccess: Approaches from above using approach_height_mm + - HorizontalAccess: Approaches from the side using approach_distance_mm + """ + await self._set_joint_angles(self.location_index, joint_position) + await self._set_grip_detail(access) + await self._move_to_stored_location_appro(self.location_index, self.profile_index) + + async def _pick_plate_j(self, joint_position: Dict[int, float], access: AccessPattern): + """Pick a plate from the specified position using joint coordinates.""" + await self._set_joint_angles(self.location_index, joint_position) + await self._set_grip_detail(access) + horizontal_compliance_int = 1 if self.horizontal_compliance else 0 + ret_code = await self._driver.send_command( + f"pickplate {self.location_index} {horizontal_compliance_int} {self.horizontal_compliance_torque}" + ) + if ret_code == "0": + raise PreciseFlexError(-1, "the force-controlled gripper detected no plate present.") + + async def _place_plate_j(self, joint_position: Dict[int, float], access: AccessPattern): + """Place a plate at the specified position using joint coordinates.""" + await self._set_joint_angles(self.location_index, joint_position) + await self._set_grip_detail(access) + horizontal_compliance_int = 1 if self.horizontal_compliance else 0 + await self._driver.send_command( + f"placeplate {self.location_index} {horizontal_compliance_int} {self.horizontal_compliance_torque}" + ) + + async def _approach_c( + self, + cartesian_position: PreciseFlexGripperLocation, + access: AccessPattern, + ): + """Move the arm to a position above the specified coordinates. + + The approach behavior depends on the access pattern: + - VerticalAccess: Approaches from above using approach_height_mm + - HorizontalAccess: Approaches from the side using approach_distance_mm + """ + await self._set_location_xyz(self.location_index, cartesian_position) + await self._set_grip_detail(access) + if cartesian_position.orientation is not None: + orientation_int = self._convert_orientation_str_to_int(cartesian_position.orientation) + await self._set_location_config(self.location_index, orientation_int) + await self._move_to_stored_location_appro(self.location_index, self.profile_index) + + async def _pick_plate_c( + self, + cartesian_position: PreciseFlexGripperLocation, + access: AccessPattern, + ): + """Pick a plate from the specified position using Cartesian coordinates.""" + await self._set_location_xyz(self.location_index, cartesian_position) + await self._set_grip_detail(access) + if cartesian_position.orientation is not None: + orientation_int = self._convert_orientation_str_to_int(cartesian_position.orientation) + orientation_int |= 0x1000 # GPL_Single: restrict wrist to ±180° + await self._set_location_config(self.location_index, orientation_int) + horizontal_compliance_int = 1 if self.horizontal_compliance else 0 + ret_code = await self._driver.send_command( + f"pickplate {self.location_index} {horizontal_compliance_int} {self.horizontal_compliance_torque}" + ) + if ret_code == "0": + raise PreciseFlexError(-1, "the force-controlled gripper detected no plate present.") + + async def _place_plate_c( + self, + cartesian_position: PreciseFlexGripperLocation, + access: AccessPattern, + ): + """Place a plate at the specified position using Cartesian coordinates.""" + await self._set_location_xyz(self.location_index, cartesian_position) + await self._set_grip_detail(access) + if cartesian_position.orientation is not None: + orientation_int = self._convert_orientation_str_to_int(cartesian_position.orientation) + orientation_int |= 0x1000 # GPL_Single: restrict wrist to ±180° + await self._set_location_config(self.location_index, orientation_int) + horizontal_compliance_int = 1 if self.horizontal_compliance else 0 + await self._driver.send_command( + f"placeplate {self.location_index} {horizontal_compliance_int} {self.horizontal_compliance_torque}" + ) + + async def _set_grip_detail(self, access: AccessPattern): + """Configure station type for pick/place operations based on access pattern. + + Calls TCS set_station_type command to configure how the robot interprets + clearance values and performs approach/retract motions. + + Args: + access: Access pattern (VerticalAccess or HorizontalAccess) defining how to approach and retract from the location. + """ + if isinstance(access, VerticalAccess): + await self._driver.send_command( + f"StationType {self.location_index} 1 0 {access.clearance_mm} 0 {access.gripper_offset_mm}" + ) + elif isinstance(access, HorizontalAccess): + await self._driver.send_command( + f"StationType {self.location_index} 0 0 {access.clearance_mm} {access.lift_height_mm} {access.gripper_offset_mm}" + ) + else: + raise TypeError("Access pattern must be VerticalAccess or HorizontalAccess.") + + # -- GENERAL COMMANDS ------------------------------------------------------ + + async def get_base(self) -> tuple[float, float, float, float]: + """Get the robot base offset. + + Returns: + A tuple containing (x_offset, y_offset, z_offset, z_rotation) + """ + data = await self._driver.send_command("base") + parts = data.split() + if len(parts) != 4: + raise PreciseFlexError(-1, "Unexpected response format from base command.") + return (float(parts[0]), float(parts[1]), float(parts[2]), float(parts[3])) + + async def set_base( + self, x_offset: float, y_offset: float, z_offset: float, z_rotation: float + ) -> None: + """Set the robot base offset. + + Args: + x_offset: Base X offset + y_offset: Base Y offset + z_offset: Base Z offset + z_rotation: Base Z rotation + + Note: + The robot must be attached to set the base. + Setting the base pauses any robot motion in progress. + """ + await self._driver.send_command(f"base {x_offset} {y_offset} {z_offset} {z_rotation}") + + async def get_monitor_speed(self) -> int: + """Get the global system (monitor) speed. + + Returns: + Current monitor speed as a percentage (1-100) + """ + response = await self._driver.send_command("mspeed") + return int(response) + + async def set_monitor_speed(self, speed_percent: int) -> None: + """Set the global system (monitor) speed. + + Args: + speed_percent: Speed percentage between 1 and 100, where 100 means full speed. + + Raises: + ValueError: If speed_percent is not between 1 and 100. + """ + if not (1 <= speed_percent <= 100): + raise ValueError("Speed percent must be between 1 and 100") + await self._driver.send_command(f"mspeed {speed_percent}") + + async def nop(self) -> None: + """No operation command. + + Does nothing except return the standard reply. Can be used to see if the link + is active or to check for exceptions. + """ + await self._driver.send_command("nop") + + async def get_payload(self) -> int: + """Get the payload percent value for the current robot. + + Returns: + Current payload as a percentage of maximum (0-100) + """ + response = await self._driver.send_command("payload") + return int(response) + + async def set_payload(self, payload_percent: int) -> None: + """Set the payload percent of maximum for the currently selected or attached robot. + + Args: + payload_percent: Payload percentage from 0 to 100 indicating the percent of the maximum payload the robot is carrying. + + Raises: + ValueError: If payload_percent is not between 0 and 100. + + Note: + If the robot is moving, waits for the robot to stop before setting a value. + """ + if not (0 <= payload_percent <= 100): + raise ValueError("Payload percent must be between 0 and 100") + await self._driver.send_command(f"payload {payload_percent}") + + async def set_parameter( + self, + data_id: int, + value, + unit_number: Optional[int] = None, + sub_unit: Optional[int] = None, + array_index: Optional[int] = None, + ) -> None: + """Change a value in the controller's parameter database. + + Args: + data_id: DataID of parameter. + value: New parameter value. If string, will be quoted automatically. + unit_number: Unit number, usually the robot number (1 - N_ROB). + sub_unit: Sub-unit, usually 0. + array_index: Array index. + + Note: + Updated values are not saved in flash unless a save-to-flash operation + is performed (see DataID 901). + """ + if unit_number is not None and sub_unit is not None and array_index is not None: + if isinstance(value, str): + await self._driver.send_command( + f'pc {data_id} {unit_number} {sub_unit} {array_index} "{value}"' + ) + else: + await self._driver.send_command( + f"pc {data_id} {unit_number} {sub_unit} {array_index} {value}" + ) + else: + if isinstance(value, str): + await self._driver.send_command(f'pc {data_id} "{value}"') + else: + await self._driver.send_command(f"pc {data_id} {value}") + + async def get_parameter( + self, + data_id: int, + unit_number: Optional[int] = None, + sub_unit: Optional[int] = None, + array_index: Optional[int] = None, + ) -> str: + """Get the value of a numeric parameter database item. + + Args: + data_id: DataID of parameter. + unit_number: Unit number, usually the robot number (1-NROB). + sub_unit: Sub-unit, usually 0. + array_index: Array index. + + Returns: + str: The numeric value of the specified database parameter. + """ + if unit_number is not None: + if sub_unit is not None: + if array_index is not None: + response = await self._driver.send_command( + f"pd {data_id} {unit_number} {sub_unit} {array_index}" + ) + else: + response = await self._driver.send_command(f"pd {data_id} {unit_number} {sub_unit}") + else: + response = await self._driver.send_command(f"pd {data_id} {unit_number}") + else: + response = await self._driver.send_command(f"pd {data_id}") + return response + + async def reset(self, robot_number: int) -> None: + """Reset the threads associated with the specified robot. + + Stops and restarts the threads for the specified robot. Any TCP/IP connections + made by these threads are broken. This command can only be sent to the status thread. + + Args: + robot_number: The number of the robot thread to reset, from 1 to N_ROB. Must not be zero. + + Raises: + ValueError: If robot_number is zero or negative. + """ + if robot_number <= 0: + raise ValueError("Robot number must be greater than zero") + await self._driver.send_command(f"reset {robot_number}") + + async def get_selected_robot(self) -> int: + """Get the number of the currently selected robot. + + Returns: + The number of the currently selected robot. + """ + response = await self._driver.send_command("selectRobot") + return int(response) + + async def select_robot(self, robot_number: int) -> None: + """Change the robot associated with this communications link. + + Does not affect the operation or attachment state of the robot. The status thread + may select any robot or 0. Except for the status thread, a robot may only be + selected by one thread at a time. + + Args: + robot_number: The new robot to be connected to this thread (1 to N_ROB) or 0 for none. + """ + await self._driver.send_command(f"selectRobot {robot_number}") + + async def get_signal(self, signal_number: int) -> int: + """Get the value of the specified digital input or output signal. + + Args: + signal_number: The number of the digital signal to get. + + Returns: + The current signal value. + """ + response = await self._driver.send_command(f"sig {signal_number}") + sig_id, sig_val = response.split() + return int(sig_val) + + async def set_signal(self, signal_number: int, value: int) -> None: + """Set the specified digital input or output signal. + + Args: + signal_number: The number of the digital signal to set. + value: The signal value to set. 0 = off, non-zero = on. + """ + await self._driver.send_command(f"sig {signal_number} {value}") + + async def get_system_state(self) -> int: + """Get the global system state code. + + Returns: + The global system state code. Please see documentation for DataID 234. + """ + response = await self._driver.send_command("sysState") + return int(response) + + async def get_tool_transformation_values(self) -> tuple[float, float, float, float, float, float]: + """Get the current tool transformation values. + + Returns: + A tuple containing (X, Y, Z, yaw, pitch, roll) for the tool transformation. + """ + data = await self._driver.send_command("tool") + if data.startswith("tool: "): + data = data[6:] + parts = data.split() + if len(parts) != 6: + raise PreciseFlexError(-1, "Unexpected response format from tool command.") + x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts) + return (x, y, z, yaw, pitch, roll) + + async def set_tool_transformation_values( + self, x: float, y: float, z: float, yaw: float, pitch: float, roll: float + ) -> None: + """Set the robot tool transformation. + + The robot must be attached to set the tool. Setting the tool pauses any robot motion in progress. + + Args: + x: Tool X coordinate. + y: Tool Y coordinate. + z: Tool Z coordinate. + yaw: Tool yaw rotation. + pitch: Tool pitch rotation. + roll: Tool roll rotation. + """ + await self._driver.send_command(f"tool {x} {y} {z} {yaw} {pitch} {roll}") + + async def get_version(self) -> str: + """Get the current version of TCS and any installed plug-ins. + + Returns: + str: The current version information. + """ + return await self._driver.send_command("version") + + # -- LOCATION COMMANDS ----------------------------------------------------- + + async def _set_joint_angles( + self, + location_index: int, + joint_position: Dict[int, float], + ) -> None: + """Set joint angles for stored location, handling rail configuration.""" + if self._has_rail: + await self._driver.send_command( + f"locAngles {location_index} " + f"{joint_position[PFAxis.RAIL]} " + f"{joint_position[PFAxis.BASE]} " + f"{joint_position[PFAxis.SHOULDER]} " + f"{joint_position[PFAxis.ELBOW]} " + f"{joint_position[PFAxis.WRIST]} " + f"{joint_position[PFAxis.GRIPPER]}" + ) + else: + await self._driver.send_command( + f"locAngles {location_index} " + f"{joint_position[PFAxis.BASE]} " + f"{joint_position[PFAxis.SHOULDER]} " + f"{joint_position[PFAxis.ELBOW]} " + f"{joint_position[PFAxis.WRIST]} " + f"{joint_position[PFAxis.GRIPPER]}" + ) + + async def _set_location_xyz( + self, + location_index: int, + cartesian_position: PreciseFlexGripperLocation, + ) -> None: + """Set the Cartesian position values for the specified station index. + + Args: + location_index: The station index, from 1 to N_LOC. + cartesian_position: The Cartesian position to set. + """ + await self._driver.send_command( + f"locXyz {location_index} " + f"{cartesian_position.location.x} " + f"{cartesian_position.location.y} " + f"{cartesian_position.location.z} " + f"{cartesian_position.rotation.yaw} " + f"{cartesian_position.rotation.pitch} " + f"{cartesian_position.rotation.roll}" + ) + + async def _set_location_config(self, location_index: int, config_value: int) -> None: + """Set the Config property for the specified location. + + Args: + location_index: The station index, from 1 to N_LOC. + config_value: The new Config property value as a bit mask where: + - 0 = None (no configuration specified) + - 0x01 = GPL_Righty (right shouldered configuration) + - 0x02 = GPL_Lefty (left shouldered configuration) + - 0x04 = GPL_Above (elbow above the wrist) + - 0x08 = GPL_Below (elbow below the wrist) + - 0x10 = GPL_Flip (wrist pitched up) + - 0x20 = GPL_NoFlip (wrist pitched down) + - 0x1000 = GPL_Single (restrict wrist axis to +/- 180 degrees) + Values can be combined using bitwise OR. + + Raises: + ValueError: If config_value contains invalid bits or conflicting configurations. + """ + GPL_RIGHTY = 0x01 + GPL_LEFTY = 0x02 + GPL_ABOVE = 0x04 + GPL_BELOW = 0x08 + GPL_FLIP = 0x10 + GPL_NOFLIP = 0x20 + GPL_SINGLE = 0x1000 + ALL_VALID_BITS = ( + GPL_RIGHTY | GPL_LEFTY | GPL_ABOVE | GPL_BELOW | GPL_FLIP | GPL_NOFLIP | GPL_SINGLE + ) + if config_value & ~ALL_VALID_BITS: + raise ValueError(f"Invalid config bits specified: 0x{config_value:X}") + if (config_value & GPL_RIGHTY) and (config_value & GPL_LEFTY): + raise ValueError("Cannot specify both GPL_Righty and GPL_Lefty") + if (config_value & GPL_ABOVE) and (config_value & GPL_BELOW): + raise ValueError("Cannot specify both GPL_Above and GPL_Below") + if (config_value & GPL_FLIP) and (config_value & GPL_NOFLIP): + raise ValueError("Cannot specify both GPL_Flip and GPL_NoFlip") + await self._driver.send_command(f"locConfig {location_index} {config_value}") + + async def dest_c(self, arg1: int = 0) -> tuple[float, float, float, float, float, float, int]: + """Get the destination or current Cartesian location of the robot. + + Args: + arg1: Selects return value. Defaults to 0. + 0 = Return current Cartesian location if robot is not moving + 1 = Return target Cartesian location of the previous or current move + + Returns: + A tuple containing (X, Y, Z, yaw, pitch, roll, config) + If arg1 = 1 or robot is moving, returns the target location. + If arg1 = 0 and robot is not moving, returns the current location. + """ + if arg1 == 0: + data = await self._driver.send_command("destC") + else: + data = await self._driver.send_command(f"destC {arg1}") + parts = data.split() + if len(parts) != 7: + raise PreciseFlexError(-1, "Unexpected response format from destC command.") + x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts[:6]) + config = int(parts[6]) + return (x, y, z, yaw, pitch, roll, config) + + async def dest_j(self, arg1: int = 0) -> Dict[int, float]: + """Get the destination or current joint location of the robot. + + Args: + arg1: Selects return value. Defaults to 0. + 0 = Return current joint location if robot is not moving + 1 = Return target joint location of the previous or current move + + Returns: + A dict mapping PFAxis to float values. + If arg1 = 1 or robot is moving, returns the target joint positions. + If arg1 = 0 and robot is not moving, returns the current joint positions. + """ + if arg1 == 0: + data = await self._driver.send_command("destJ") + else: + data = await self._driver.send_command(f"destJ {arg1}") + parts = data.split() + if not parts: + raise PreciseFlexError(-1, "Unexpected response format from destJ command.") + return self._parse_angles_response(parts) + + async def here_j(self, location_index: int) -> None: + """Record the current position of the selected robot into the specified Location as angles. + + The Location is automatically set to type "angles". + + Args: + location_index: The station index, from 1 to N_LOC. + """ + await self._driver.send_command(f"hereJ {location_index}") + + async def here_c(self, location_index: int) -> None: + """Record the current position of the selected robot into the specified Location as Cartesian. + + The Location object is automatically set to type "Cartesian". + Can be used to change the pallet origin (index 1,1,1) value. + + Args: + location_index: The station index, from 1 to N_LOC. + """ + await self._driver.send_command(f"hereC {location_index}") + + # -- PROFILE COMMANDS ------------------------------------------------------ + + async def get_profile_speed(self, profile_index: int) -> float: + """Get the speed property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current speed as a percentage. 100 = full speed. + """ + response = await self._driver.send_command(f"Speed {profile_index}") + profile, speed = response.split() + return float(speed) + + async def set_profile_speed(self, profile_index: int, speed_percent: float) -> None: + """Set the speed property of the specified profile. + + Args: + profile_index: The profile index to modify. + speed_percent: The new speed as a percentage. 100 = full speed. + Values > 100 may be accepted depending on system configuration. + """ + await self._driver.send_command(f"Speed {profile_index} {speed_percent}") + + async def get_profile_speed2(self, profile_index: int) -> float: + """Get the speed2 property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current speed2 as a percentage. Used for Cartesian moves. + """ + response = await self._driver.send_command(f"Speed2 {profile_index}") + profile, speed2 = response.split() + return float(speed2) + + async def set_profile_speed2(self, profile_index: int, speed2_percent: float) -> None: + """Set the speed2 property of the specified profile. + + Args: + profile_index: The profile index to modify. + speed2_percent: The new speed2 as a percentage. 100 = full speed. + Used for Cartesian moves. Normally set to 0. + """ + await self._driver.send_command(f"Speed2 {profile_index} {speed2_percent}") + + async def get_profile_accel(self, profile_index: int) -> float: + """Get the acceleration property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current acceleration as a percentage. 100 = maximum acceleration. + """ + response = await self._driver.send_command(f"Accel {profile_index}") + profile, accel = response.split() + return float(accel) + + async def set_profile_accel(self, profile_index: int, accel_percent: float) -> None: + """Set the acceleration property of the specified profile. + + Args: + profile_index: The profile index to modify. + accel_percent: The new acceleration as a percentage. 100 = maximum acceleration. + Maximum value depends on system configuration. + """ + await self._driver.send_command(f"Accel {profile_index} {accel_percent}") + + async def get_profile_accel_ramp(self, profile_index: int) -> float: + """Get the acceleration ramp property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current acceleration ramp time in seconds. + """ + response = await self._driver.send_command(f"AccRamp {profile_index}") + profile, accel_ramp = response.split() + return float(accel_ramp) + + async def set_profile_accel_ramp(self, profile_index: int, accel_ramp_seconds: float) -> None: + """Set the acceleration ramp property of the specified profile. + + Args: + profile_index: The profile index to modify. + accel_ramp_seconds: The new acceleration ramp time in seconds. + """ + await self._driver.send_command(f"AccRamp {profile_index} {accel_ramp_seconds}") + + async def get_profile_decel(self, profile_index: int) -> float: + """Get the deceleration property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current deceleration as a percentage. 100 = maximum deceleration. + """ + response = await self._driver.send_command(f"Decel {profile_index}") + profile, decel = response.split() + return float(decel) + + async def set_profile_decel(self, profile_index: int, decel_percent: float) -> None: + """Set the deceleration property of the specified profile. + + Args: + profile_index: The profile index to modify. + decel_percent: The new deceleration as a percentage. 100 = maximum deceleration. + Maximum value depends on system configuration. + """ + await self._driver.send_command(f"Decel {profile_index} {decel_percent}") + + async def get_profile_decel_ramp(self, profile_index: int) -> float: + """Get the deceleration ramp property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current deceleration ramp time in seconds. + """ + response = await self._driver.send_command(f"DecRamp {profile_index}") + profile, decel_ramp = response.split() + return float(decel_ramp) + + async def set_profile_decel_ramp(self, profile_index: int, decel_ramp_seconds: float) -> None: + """Set the deceleration ramp property of the specified profile. + + Args: + profile_index: The profile index to modify. + decel_ramp_seconds: The new deceleration ramp time in seconds. + """ + await self._driver.send_command(f"DecRamp {profile_index} {decel_ramp_seconds}") + + async def get_profile_in_range(self, profile_index: int) -> float: + """Get the InRange property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + float: The current InRange value (-1 to 100). + -1 = do not stop at end of motion if blending is possible + 0 = always stop but do not check end point error + > 0 = wait until close to end point (larger numbers mean less position error allowed) + """ + response = await self._driver.send_command(f"InRange {profile_index}") + profile, in_range = response.split() + return float(in_range) + + async def set_profile_in_range(self, profile_index: int, in_range_value: float) -> None: + """Set the InRange property of the specified profile. + + Args: + profile_index: The profile index to modify. + in_range_value: The new InRange value from -1 to 100. + -1 = do not stop at end of motion if blending is possible + 0 = always stop but do not check end point error + > 0 = wait until close to end point (larger numbers mean less position error allowed) + + Raises: + ValueError: If in_range_value is not between -1 and 100. + """ + if not (-1 <= in_range_value <= 100): + raise ValueError("InRange value must be between -1 and 100") + await self._driver.send_command(f"InRange {profile_index} {in_range_value}") + + async def get_profile_straight(self, profile_index: int) -> bool: + """Get the Straight property of the specified profile. + + Args: + profile_index: The profile index to query. + + Returns: + The current Straight property value. + True = follow a straight-line path + False = follow a joint-based path (coordinated axes movement) + """ + response = await self._driver.send_command(f"Straight {profile_index}") + profile, straight = response.split() + return straight == "True" + + async def set_profile_straight(self, profile_index: int, straight_mode: bool) -> None: + """Set the Straight property of the specified profile. + + Args: + profile_index: The profile index to modify. + straight_mode: The path type to use. + True = follow a straight-line path + False = follow a joint-based path (robot axes move in coordinated manner) + + Raises: + ValueError: If straight_mode is not True or False. + """ + straight_int = 1 if straight_mode else 0 + await self._driver.send_command(f"Straight {profile_index} {straight_int}") + + async def set_motion_profile_values( + self, + profile: int, + speed: float, + speed2: float, + acceleration: float, + deceleration: float, + acceleration_ramp: float, + deceleration_ramp: float, + in_range: float, + straight: bool, + ): + """ + Set motion profile values for the specified profile index on the PreciseFlex robot. + + Args: + profile: Profile index to set values for. + speed: Percentage of maximum speed. 100 = full speed. Values >100 may be accepted depending on system config. + speed2: Secondary speed setting, typically for Cartesian moves. Normally 0. Interpreted as a percentage. + acceleration: Percentage of maximum acceleration. 100 = full accel. + deceleration: Percentage of maximum deceleration. 100 = full decel. + acceleration_ramp: Acceleration ramp time in seconds. + deceleration_ramp: Deceleration ramp time in seconds. + in_range: InRange value, from -1 to 100. -1 = allow blending, 0 = stop without checking, >0 = enforce position accuracy. + straight: If True, follow a straight-line path (-1). If False, follow a joint-based path (0). + """ + if not (0 <= speed): + raise ValueError("Speed must be > 0 (percent).") + if not (0 <= speed2): + raise ValueError("Speed2 must be > 0 (percent).") + if not (0 <= acceleration <= 100): + raise ValueError("Acceleration must be between 0 and 100 (percent).") + if not (0 <= deceleration <= 100): + raise ValueError("Deceleration must be between 0 and 100 (percent).") + if acceleration_ramp < 0: + raise ValueError("Acceleration ramp must be >= 0 (seconds).") + if deceleration_ramp < 0: + raise ValueError("Deceleration ramp must be >= 0 (seconds).") + if not (-1 <= in_range <= 100): + raise ValueError("InRange must be between -1 and 100.") + straight_int = -1 if straight else 0 + await self._driver.send_command( + f"Profile {profile} {speed} {speed2} {acceleration} {deceleration} " + f"{acceleration_ramp} {deceleration_ramp} {in_range} {straight_int}" + ) + + async def get_motion_profile_values( + self, profile: int + ) -> tuple[int, float, float, float, float, float, float, float, bool]: + """ + Get the current motion profile values for the specified profile index on the PreciseFlex robot. + + Args: + profile: Profile index to get values for. + + Returns: + A tuple containing (profile, speed, speed2, acceleration, deceleration, acceleration_ramp, deceleration_ramp, in_range, straight) + - profile: Profile index + - speed: Percentage of maximum speed + - speed2: Secondary speed setting + - acceleration: Percentage of maximum acceleration + - deceleration: Percentage of maximum deceleration + - acceleration_ramp: Acceleration ramp time in seconds + - deceleration_ramp: Deceleration ramp time in seconds + - in_range: InRange value (-1 to 100) + - straight: True if straight-line path, False if joint-based path + """ + data = await self._driver.send_command(f"Profile {profile}") + parts = data.split(" ") + if len(parts) != 9: + raise PreciseFlexError(-1, "Unexpected response format from device.") + return ( + int(parts[0]), + float(parts[1]), + float(parts[2]), + float(parts[3]), + float(parts[4]), + float(parts[5]), + float(parts[6]), + float(parts[7]), + int(parts[8]) != 0, + ) + + # -- RAIL COMMANDS --------------------------------------------------------- + + async def _set_rail_position(self, station_id: int, rail_position: float) -> None: + """Set the rail position for the specified station. + + Args: + station_id: The station index. + rail_position: The rail position in mm. + """ + await self._driver.send_command(f"Rail {station_id} {rail_position}") + + async def _move_rail(self, station_id: Optional[int] = None, mode: int = 1) -> None: + """Move the rail to the position stored at the specified station. + + Args: + station_id: The station index whose rail position to move to. + mode: Motion mode (0 = normal). + """ + if station_id is not None: + await self._driver.send_command(f"MoveRail {station_id} {mode}") + else: + await self._driver.send_command(f"MoveRail {mode}") + + # -- MOTION COMMANDS ------------------------------------------------------- + + async def _move_to_stored_location(self, location_index: int, profile_index: int) -> None: + """Move to the location specified by the station index using the specified profile. + + Args: + location_index: The index of the location to which the robot moves. + profile_index: The profile index for this move. + + Note: + Requires that the robot be attached. + """ + await self._driver.send_command(f"move {location_index} {profile_index}") + + async def _move_to_stored_location_appro(self, location_index: int, profile_index: int) -> None: + """Approach the location specified by the station index using the specified profile. + + This is similar to `_move_to_stored_location` except that the Z clearance value is included. + + Args: + location_index: The index of the location to which the robot moves. + profile_index: The profile index for this move. + + Note: + Requires that the robot be attached. + """ + await self._driver.send_command(f"moveAppro {location_index} {profile_index}") + + async def _move_c( + self, + profile_index: int, + cartesian_coords: PreciseFlexGripperLocation, + ) -> None: + """Move the robot to the Cartesian location specified by the arguments. + + Args: + profile_index: The profile index to use for this motion. + cartesian_coords: The Cartesian coordinates to which the robot should move. + + Note: + Requires that the robot be attached. + """ + cmd = ( + f"moveC {profile_index} " + f"{cartesian_coords.location.x} " + f"{cartesian_coords.location.y} " + f"{cartesian_coords.location.z} " + f"{cartesian_coords.rotation.yaw} " + f"{cartesian_coords.rotation.pitch} " + f"{cartesian_coords.rotation.roll} " + ) + if cartesian_coords.orientation is not None: + config_int = self._convert_orientation_str_to_int(cartesian_coords.orientation) + config_int |= 0x1000 + cmd += f"{config_int}" + await self._driver.send_command(cmd) + + async def _move_j(self, profile_index: int, joint_coords: Dict[int, float]) -> None: + """Move the robot using joint coordinates, handling rail configuration.""" + if self._has_rail: + angles_str = ( + f"{joint_coords[PFAxis.BASE]} " + f"{joint_coords[PFAxis.SHOULDER]} " + f"{joint_coords[PFAxis.ELBOW]} " + f"{joint_coords[PFAxis.WRIST]} " + f"{joint_coords[PFAxis.GRIPPER]} " + f"{joint_coords[PFAxis.RAIL]} " + ) + else: + angles_str = ( + f"{joint_coords[PFAxis.BASE]} " + f"{joint_coords[PFAxis.SHOULDER]} " + f"{joint_coords[PFAxis.ELBOW]} " + f"{joint_coords[PFAxis.WRIST]} " + f"{joint_coords[PFAxis.GRIPPER]}" + ) + await self._driver.send_command(f"moveJ {profile_index} {angles_str}") + + async def release_brake(self, axis: int) -> None: + """Release the axis brake. + + Overrides the normal operation of the brake. It is important that the brake not be set + while a motion is being performed. This feature is used to lock an axis to prevent + motion or jitter. + + Args: + axis: The number of the axis whose brake should be released. + """ + await self._driver.send_command(f"releaseBrake {axis}") + + async def set_brake(self, axis: int) -> None: + """Set the axis brake. + + Overrides the normal operation of the brake. It is important not to set a brake on an + axis that is moving as it may damage the brake or damage the motor. + + Args: + axis: The number of the axis whose brake should be set. + """ + await self._driver.send_command(f"setBrake {axis}") + + async def zero_torque(self, enable: bool, axis_mask: int = 1) -> None: + """Sets or clears zero torque mode for the selected robot. + + Individual axes may be placed into zero torque mode while the remaining axes are servoing. + + Args: + enable: If True, enable torque mode for axes specified by axis_mask. If False, disable torque mode for the entire robot. + axis_mask: The bit mask specifying the axes to be placed in torque mode when enable is True. The mask is computed by OR'ing the axis bits: 1 = axis 1, 2 = axis 2, 4 = axis 3, 8 = axis 4, etc. Ignored when enable is False. + """ + if enable: + assert axis_mask > 0, "axis_mask must be greater than 0" + await self._driver.send_command(f"zeroTorque 1 {axis_mask}") + else: + await self._driver.send_command("zeroTorque 0") + + # -- PAROBOT COMMANDS ------------------------------------------------------ + + async def change_config(self, grip_mode: int = 0) -> None: + """Change Robot configuration from Righty to Lefty or vice versa using customizable locations. + + Uses customizable locations to avoid hitting robot during change. + Does not include checks for collision inside work volume of the robot. + Can be customized by user for their work cell configuration. + + Args: + grip_mode: Gripper control mode. + 0 = do not change gripper (default) + 1 = open gripper + 2 = close gripper + """ + await self._driver.send_command(f"ChangeConfig {grip_mode}") + + async def change_config2(self, grip_mode: int = 0) -> None: + """Change Robot configuration from Righty to Lefty or vice versa using algorithm. + + Uses an algorithm to avoid hitting robot during change. + Does not include checks for collision inside work volume of the robot. + Can be customized by user for their work cell configuration. + + Args: + grip_mode: Gripper control mode. + 0 = do not change gripper (default) + 1 = open gripper + 2 = close gripper + """ + await self._driver.send_command(f"ChangeConfig2 {grip_mode}") + + async def _get_grasp_data(self) -> tuple[float, float, float]: + """Get the data to be used for the next force-controlled PickPlate command grip operation. + + Returns: + A tuple containing (plate_width_mm, finger_speed_percent, grasp_force) + """ + data = await self._driver.send_command("GraspData") + parts = data.split() + if len(parts) != 3: + raise PreciseFlexError(-1, "Unexpected response format from GraspData command.") + return (float(parts[0]), float(parts[1]), float(parts[2])) + + async def _set_grasp_data( + self, plate_width: float, finger_speed_percent: float, grasp_force: float + ) -> None: + """Set the data to be used for the next force-controlled PickPlate command grip operation. + + This data remains in effect until the next GraspData command or the system is restarted. + + Args: + plate_width: The plate width in mm. + finger_speed_percent: The finger speed during grasp where 100 means 100%. + grasp_force: The gripper squeezing force, in Newtons. + A positive value indicates the fingers must close to grasp. + A negative value indicates the fingers must open to grasp. + """ + await self._driver.send_command(f"GraspData {plate_width} {finger_speed_percent} {grasp_force}") + + async def _get_grip_close_pos(self) -> float: + """Get the gripper close position for the servoed gripper. + + Returns: + float: The current gripper close position. + """ + data = await self._driver.send_command("GripClosePos") + return float(data) + + async def _set_grip_close_pos(self, close_position: float) -> None: + """Set the gripper close position for the servoed gripper. + + The close position may be changed by a force-controlled grip operation. + + Args: + close_position: The new gripper close position. + """ + await self._driver.send_command(f"GripClosePos {close_position}") + + async def _get_grip_open_pos(self) -> float: + """Get the gripper open position for the servoed gripper. + + Returns: + float: The current gripper open position. + """ + data = await self._driver.send_command("GripOpenPos") + return float(data) + + async def _set_grip_open_pos(self, open_position: float) -> None: + """Set the gripper open position for the servoed gripper. + + Args: + open_position: The new gripper open position. + """ + await self._driver.send_command(f"GripOpenPos {open_position}") + + # -- parsing helpers ------------------------------------------------------- + + def _parse_xyz_response( + self, parts: List[str] + ) -> tuple[float, float, float, float, float, float]: + if len(parts) != 6: + raise PreciseFlexError(-1, "Unexpected response format for Cartesian coordinates.") + return ( + float(parts[0]), + float(parts[1]), + float(parts[2]), + float(parts[3]), + float(parts[4]), + float(parts[5]), + ) + + def _parse_angles_response(self, parts: List[str]) -> Dict[int, float]: + """Parse angle values from a response string. + + For self._has_rail=True: wire order is [base, shoulder, elbow, wrist, gripper, rail] + For self._has_rail=False: wire order is [base, shoulder, elbow, wrist, gripper] + """ + if len(parts) < 3: + raise PreciseFlexError(-1, "Unexpected response format for angles.") + if self._has_rail: + return { + PFAxis.RAIL: float(parts[5]) if len(parts) > 5 else 0.0, + PFAxis.BASE: float(parts[0]), + PFAxis.SHOULDER: float(parts[1]), + PFAxis.ELBOW: float(parts[2]), + PFAxis.WRIST: float(parts[3]) if len(parts) > 3 else 0.0, + PFAxis.GRIPPER: float(parts[4]) if len(parts) > 4 else 0.0, + } + return { + PFAxis.RAIL: 0.0, + PFAxis.BASE: float(parts[0]), + PFAxis.SHOULDER: float(parts[1]), + PFAxis.ELBOW: float(parts[2]) if len(parts) > 2 else 0.0, + PFAxis.WRIST: float(parts[3]) if len(parts) > 3 else 0.0, + PFAxis.GRIPPER: float(parts[4]) if len(parts) > 4 else 0.0, + } + + +# --------------------------------------------------------------------------- +# Concrete model backends +# --------------------------------------------------------------------------- + + +class PreciseFlex400(Device): + """Backend for the PreciseFlex 400 robotic arm.""" + + def __init__( + self, host: str, port: int = 10100, has_rail: bool = False, timeout: int = 20 + ) -> None: + driver = PreciseFlexDriver(host=host, port=port, timeout=timeout) + super().__init__(driver=driver) + self._driver: PreciseFlexDriver = driver + backend = PreciseFlexArmBackend(driver=driver, has_rail=has_rail) + self.reference = Resource(name="PreciseFlex400", size_x=200, size_y=200, size_z=200) + self.arm = OrientableArm(backend=backend, reference_resource=self.reference) + self._capabilities = [self.arm] + + +class PreciseFlex3400Backend(PreciseFlexArmBackend): + """Backend for the PreciseFlex 3400 robotic arm.""" + + def __init__( + self, + driver: PreciseFlexDriver, + has_rail: bool = False, + ) -> None: + super().__init__(driver=driver, has_rail=has_rail) diff --git a/pylabrobot/hamilton/liquid_handlers/star/core.py b/pylabrobot/hamilton/liquid_handlers/star/core.py new file mode 100644 index 00000000000..a1a3156f8d6 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/core.py @@ -0,0 +1,197 @@ +from dataclasses import dataclass +from typing import Optional + +from pylabrobot.arms.backend import GripperArmBackend +from pylabrobot.arms.standard import GripperLocation +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.legacy.liquid_handling.backends.hamilton.base import HamiltonLiquidHandler +from pylabrobot.resources import Coordinate + + +class CoreGripper(GripperArmBackend): + """Backend for Hamilton CoRe gripper tools. + + The CoRe gripper uses two pipetting channels to grip plates along the Y axis. + Tool management (pick up / return) is handled by the STAR backend. + """ + + def __init__(self, interface: HamiltonLiquidHandler): + self.interface = interface + + # -- lifecycle -------------------------------------------------------------- + + async def get_gripper_location(self, backend_params=None) -> GripperLocation: + raise NotImplementedError("CoreGripper does not support get_gripper_location") + + # -- ArmBackend interface --------------------------------------------------- + + @dataclass + class PickUpParams(BackendParams): + grip_strength: int = 15 + y_gripping_speed: float = 5.0 + z_speed: float = 50.0 + minimum_traverse_height: float = 280.0 + z_position_at_end: float = 280.0 + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up a plate at the specified location. + + Args: + location: Plate center position [mm]. + resource_width: Plate width in Y direction [mm]. + backend_params: CoreGripper.PickUpParams for firmware-specific settings. + """ + if not isinstance(backend_params, CoreGripper.PickUpParams): + backend_params = CoreGripper.PickUpParams() + + open_gripper_position = resource_width + 3.0 + plate_width = resource_width - 3.0 + + if not 0 <= abs(location.x) <= 3000.0: + raise ValueError("x_position must be between -3000.0 and 3000.0") + if not 0 <= abs(location.y) <= 650.0: + raise ValueError("y_position must be between -650.0 and 650.0") + if not 0 <= abs(location.z) <= 360.0: + raise ValueError("z_position must be between -360.0 and 360.0") + if not 0 <= backend_params.grip_strength <= 99: + raise ValueError("grip_strength must be between 0 and 99") + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + if not 0 <= backend_params.z_position_at_end <= 360.0: + raise ValueError("z_position_at_end must be between 0 and 360.0") + + await self.interface.send_command( + module="C0", + command="ZP", + xs=f"{abs(round(location.x * 10)):05}", + xd=int(location.x < 0), + yj=f"{abs(round(location.y * 10)):04}", + yv=f"{round(backend_params.y_gripping_speed * 10):04}", + zj=f"{abs(round(location.z * 10)):04}", + zy=f"{round(backend_params.z_speed * 10):04}", + yo=f"{round(open_gripper_position * 10):04}", + yg=f"{round(plate_width * 10):04}", + yw=f"{backend_params.grip_strength:02}", + th=f"{round(backend_params.minimum_traverse_height * 10):04}", + te=f"{round(backend_params.z_position_at_end * 10):04}", + ) + + @dataclass + class DropParams(BackendParams): + z_press_on_distance: float = 0.0 + z_speed: float = 50.0 + minimum_traverse_height: float = 280.0 + z_position_at_end: float = 280.0 + + async def drop_at_location( + self, + location: Coordinate, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop a plate at the specified location. + + Args: + location: Plate center position [mm]. + resource_width: Plate width [mm]. Used to compute open gripper position. + backend_params: CoreGripper.DropParams for firmware-specific settings. + """ + if not isinstance(backend_params, CoreGripper.DropParams): + backend_params = CoreGripper.DropParams() + + open_gripper_position = resource_width + 3.0 + + if not 0 <= abs(location.x) <= 3000.0: + raise ValueError("x_position must be between -3000.0 and 3000.0") + if not 0 <= abs(location.y) <= 650.0: + raise ValueError("y_position must be between -650.0 and 650.0") + if not 0 <= abs(location.z) <= 360.0: + raise ValueError("z_position must be between -360.0 and 360.0") + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + if not 0 <= backend_params.z_position_at_end <= 360.0: + raise ValueError("z_position_at_end must be between 0 and 360.0") + + await self.interface.send_command( + module="C0", + command="ZR", + xs=f"{abs(round(location.x * 10)):05}", + xd=int(location.x < 0), + yj=f"{abs(round(location.y * 10)):04}", + zj=f"{abs(round(location.z * 10)):04}", + zi=f"{round(backend_params.z_press_on_distance * 10):03}", + zy=f"{round(backend_params.z_speed * 10):04}", + yo=f"{round(open_gripper_position * 10):04}", + th=f"{round(backend_params.minimum_traverse_height * 10):04}", + te=f"{round(backend_params.z_position_at_end * 10):04}", + ) + + @dataclass + class MoveToLocationParams(BackendParams): + acceleration_index: int = 4 + z_speed: float = 50.0 + minimum_traverse_height: float = 280.0 + + async def move_to_location( + self, + location: Coordinate, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move a held plate to a new position without releasing it. + + Args: + location: Target plate center position [mm]. + backend_params: CoreGripper.MoveToLocationParams for firmware-specific settings. + """ + if not isinstance(backend_params, CoreGripper.MoveToLocationParams): + backend_params = CoreGripper.MoveToLocationParams() + + if not 0 <= abs(location.x) <= 3000.0: + raise ValueError("x_position must be between -3000.0 and 3000.0") + if not 0 <= abs(location.y) <= 650.0: + raise ValueError("y_position must be between -650.0 and 650.0") + if not 0 <= abs(location.z) <= 360.0: + raise ValueError("z_position must be between -360.0 and 360.0") + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + + await self.interface.send_command( + module="C0", + command="ZM", + xs=f"{abs(round(location.x * 10)):05}", + xd=int(location.x < 0), + xg=backend_params.acceleration_index, + yj=f"{abs(round(location.y * 10)):04}", + zj=f"{abs(round(location.z * 10)):04}", + zy=f"{round(backend_params.z_speed * 10):04}", + th=f"{round(backend_params.minimum_traverse_height * 10):04}", + ) + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Open the CoRe gripper.""" + await self.interface.send_command(module="C0", command="ZO") + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + raise NotImplementedError( + "CoreGripper does not support close_gripper directly. Use pick_up_at_location instead." + ) + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + raise NotImplementedError("CoreGripper does not support is_gripper_closed") + + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + raise NotImplementedError("CoreGripper does not support halt") + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + raise NotImplementedError( + "CoreGripper does not support park. Tool management is handled by the STAR backend." + ) diff --git a/pylabrobot/hamilton/liquid_handlers/star/core_test.ipynb b/pylabrobot/hamilton/liquid_handlers/star/core_test.ipynb new file mode 100644 index 00000000000..5140927f426 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/core_test.ipynb @@ -0,0 +1,259 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# CoreGripper + Arm: Simple Resource Move Test\n", + "\n", + "Tests the `CoreGripper` backend through `Arm` with real PLR resources and the real STAR firmware interface.\n", + "\n", + "Tool management (pick up / return gripper tools) is still handled by the STAR backend." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.arms.arm import Arm\n", + "from pylabrobot.hamilton.liquid_handlers.star.core import CoreGripper\n", + "from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import STARBackend\n", + "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n", + "from pylabrobot.resources.hamilton.hamilton_decks import STARDeck\n", + "from pylabrobot.resources.hamilton.plate_carriers import PLT_CAR_L5AC_A00" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Set up deck with carrier and plate" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Plate 'my_plate' is in: carrier-1\n", + "Plate absolute location: Coordinate(396.500, 167.500, 183.120)\n", + "Destination site: carrier-2\n", + "Destination location: Coordinate(396.500, 263.500, 186.150)\n" + ] + } + ], + "source": [ + "deck = STARDeck(core_grippers=\"1000uL-at-waste\")\n", + "\n", + "carrier = PLT_CAR_L5AC_A00(\"carrier\")\n", + "deck.assign_child_resource(carrier, rails=14)\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(\"my_plate\")\n", + "carrier[1].assign_child_resource(plate)\n", + "\n", + "print(f\"Plate '{plate.name}' is in: {plate.parent.name}\")\n", + "print(f\"Plate absolute location: {plate.get_absolute_location()}\")\n", + "print(f\"Destination site: {carrier[2].name}\")\n", + "print(f\"Destination location: {carrier[2].get_absolute_location()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create CoreGripper backend with real STAR interface" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-03-21 15:31:31,715 - pylabrobot.io.usb - INFO - Finding USB device...\n", + "2026-03-21 15:31:31,720 - pylabrobot.io.usb - INFO - Found USB device.\n", + "2026-03-21 15:31:31,720 - pylabrobot.io.usb - INFO - Found endpoints. \n", + "Write:\n", + " ENDPOINT 0x2: Bulk OUT ===============================\n", + " bLength : 0x7 (7 bytes)\n", + " bDescriptorType : 0x5 Endpoint\n", + " bEndpointAddress : 0x2 OUT\n", + " bmAttributes : 0x2 Bulk\n", + " wMaxPacketSize : 0x40 (64 bytes)\n", + " bInterval : 0x0 \n", + "Read:\n", + " ENDPOINT 0x81: Bulk IN ===============================\n", + " bLength : 0x7 (7 bytes)\n", + " bDescriptorType : 0x5 Endpoint\n", + " bEndpointAddress : 0x81 IN\n", + " bmAttributes : 0x2 Bulk\n", + " wMaxPacketSize : 0x40 (64 bytes)\n", + " bInterval : 0x0\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Arm ready\n" + ] + } + ], + "source": [ + "star_backend = STARBackend()\n", + "star_backend.set_deck(deck)\n", + "await star_backend.setup()\n", + "\n", + "core_backend = CoreGripper(interface=star_backend)\n", + "\n", + "arm = Arm(backend=core_backend, reference_resource=deck, grip_axis=\"y\")\n", + "print(\"Arm ready\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pick up core gripper tools\n", + "\n", + "Tool management is handled by the legacy STAR backend for now." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Core gripper tools picked up, core_parked: False\n" + ] + } + ], + "source": [ + "await star_backend.pick_up_core_gripper_tools(front_channel=7)\n", + "print(f\"Core gripper tools picked up, core_parked: {star_backend.core_parked}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pick up plate from carrier[1]" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "arm._end_holding()" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Picked up 'my_plate'\n" + ] + } + ], + "source": [ + "await arm.pick_up_resource(plate)\n", + "print(f\"Picked up '{plate.name}'\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Drop plate at carrier[2]" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Plate 'my_plate' is now in: carrier-0\n", + "Plate absolute location: Coordinate(396.500, 071.500, 183.120)\n" + ] + } + ], + "source": [ + "await arm.drop_resource(carrier[0])\n", + "print(f\"Plate '{plate.name}' is now in: {plate.parent.name}\")\n", + "print(f\"Plate absolute location: {plate.get_absolute_location()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Return core gripper tools" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await star_backend.return_core_gripper_tools()\n", + "print(f\"Core gripper tools returned, core_parked: {star_backend.core_parked}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.15" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/pylabrobot/hamilton/liquid_handlers/star/core_tests.py b/pylabrobot/hamilton/liquid_handlers/star/core_tests.py new file mode 100644 index 00000000000..7ae9b818a0a --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/core_tests.py @@ -0,0 +1,166 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.hamilton.liquid_handlers.star.core import CoreGripper +from pylabrobot.resources import Coordinate + + +class TestCoreGripperCommands(unittest.IsolatedAsyncioTestCase): + """Test that CoreGripper methods produce the exact same firmware commands as the legacy + STARBackend equivalents.""" + + async def asyncSetUp(self): + self.mock_interface = MagicMock() + self.mock_interface.send_command = AsyncMock() + self.core = CoreGripper(interface=self.mock_interface) + + async def test_pick_up_at_location(self): + """ZP with default params, plate width 86mm at (347.9, 114.2, 187.4).""" + await self.core.pick_up_at_location( + location=Coordinate(347.9, 114.2, 187.4), + resource_width=86.0, + ) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="ZP", + xs="03479", + xd=0, + yj="1142", + yv="0050", + zj="1874", + zy="0500", + yo="0890", + yg="0830", + yw="15", + th="2800", + te="2800", + ) + + async def test_pick_up_at_location_custom_params(self): + """ZP with custom grip strength and speeds.""" + await self.core.pick_up_at_location( + location=Coordinate(500.0, 200.0, 150.0), + resource_width=127.76, + backend_params=CoreGripper.PickUpParams( + grip_strength=20, + y_gripping_speed=10.0, + z_speed=80.0, + minimum_traverse_height=300.0, + z_position_at_end=290.0, + ), + ) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="ZP", + xs="05000", + xd=0, + yj="2000", + yv="0100", + zj="1500", + zy="0800", + yo="1308", + yg="1248", + yw="20", + th="3000", + te="2900", + ) + + async def test_drop_at_location(self): + """ZR with default params, plate width 86mm at (347.9, 306.2, 187.4).""" + await self.core.drop_at_location( + location=Coordinate(347.9, 306.2, 187.4), + resource_width=86.0, + ) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="ZR", + xs="03479", + xd=0, + yj="3062", + zj="1874", + zi="000", + zy="0500", + yo="0890", + th="2800", + te="2800", + ) + + async def test_drop_at_location_custom_params(self): + """ZR with custom press distance.""" + await self.core.drop_at_location( + location=Coordinate(500.0, 200.0, 150.0), + resource_width=86.0, + backend_params=CoreGripper.DropParams( + z_press_on_distance=5.0, + z_speed=30.0, + minimum_traverse_height=300.0, + z_position_at_end=290.0, + ), + ) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="ZR", + xs="05000", + xd=0, + yj="2000", + zj="1500", + zi="050", + zy="0300", + yo="0890", + th="3000", + te="2900", + ) + + async def test_move_to_location(self): + """ZM with default params at (500.0, 200.0, 150.0).""" + await self.core.move_to_location( + location=Coordinate(500.0, 200.0, 150.0), + ) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="ZM", + xs="05000", + xd=0, + xg=4, + yj="2000", + zj="1500", + zy="0500", + th="2800", + ) + + async def test_move_to_location_custom_params(self): + """ZM with custom acceleration and speed.""" + await self.core.move_to_location( + location=Coordinate(800.0, 300.0, 200.0), + backend_params=CoreGripper.MoveToLocationParams( + acceleration_index=2, + z_speed=30.0, + minimum_traverse_height=350.0, + ), + ) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="ZM", + xs="08000", + xd=0, + xg=2, + yj="3000", + zj="2000", + zy="0300", + th="3500", + ) + + async def test_open_gripper(self): + """ZO command.""" + await self.core.open_gripper(gripper_width=0) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="ZO", + ) diff --git a/pylabrobot/hamilton/liquid_handlers/star/iswap.py b/pylabrobot/hamilton/liquid_handlers/star/iswap.py new file mode 100644 index 00000000000..4eacb09866b --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/iswap.py @@ -0,0 +1,336 @@ +from dataclasses import dataclass +from typing import Optional, cast + +from pylabrobot.arms.backend import OrientableGripperArmBackend +from pylabrobot.arms.standard import GripperLocation +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.legacy.liquid_handling.backends.hamilton.base import HamiltonLiquidHandler +from pylabrobot.resources import Coordinate + + +def _direction_degrees_to_grip_direction(degrees: float) -> int: + """Convert rotation angle in degrees to firmware grip_direction (1-4). + + Firmware: 1 = negative Y (front), 2 = positive X (right), + 3 = positive Y (back), 4 = negative X (left). + """ + normalized = round(degrees) % 360 + mapping = {0: 1, 90: 2, 180: 3, 270: 4} + if normalized not in mapping: + raise ValueError(f"grip direction must be a multiple of 90 degrees, got {degrees}") + return mapping[normalized] + + +class iSWAP(OrientableGripperArmBackend): + def __init__(self, interface: HamiltonLiquidHandler): + self.interface = interface + self._version: Optional[str] = None + self._parked: Optional[bool] = None + + @property + def version(self) -> str: + """Firmware version string. Available after setup.""" + if self._version is None: + raise RuntimeError("iSWAP version not loaded. Call setup() first.") + return self._version + + @property + def parked(self) -> bool: + return self._parked is True + + async def get_gripper_location(self, backend_params=None) -> GripperLocation: + raise NotImplementedError("iSWAP does not support get_gripper_location") + + async def _on_setup(self) -> None: + self._version = await self._request_version() + + async def _request_version(self) -> str: + """Request the iSWAP firmware version from the device.""" + return cast(str, (await self.interface.send_command("R0", "RF", fmt="rf" + "&" * 15))["rf"]) + + @dataclass + class ParkParams(BackendParams): + minimum_traverse_height: float = 284.0 + + async def park(self, backend_params: Optional[BackendParams] = None) -> None: + """Park the iSWAP. + + Args: + backend_params: iSWAP.ParkParams with minimum_traverse_height. + """ + if not isinstance(backend_params, iSWAP.ParkParams): + backend_params = iSWAP.ParkParams() + + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + + await self.interface.send_command( + module="C0", + command="PG", + th=round(backend_params.minimum_traverse_height * 10), + ) + self._parked = True + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Open the iSWAP gripper. + + Args: + gripper_width: Open position [mm]. + backend_params: Unused, reserved for future use. + """ + if not 0 <= gripper_width <= 999.9: + raise ValueError("gripper_width must be between 0 and 999.9") + + await self.interface.send_command( + module="C0", command="GF", go=f"{round(gripper_width * 10):04}" + ) + + @dataclass + class CloseGripperParams(BackendParams): + grip_strength: int = 5 + plate_width_tolerance: float = 0 + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + """Close the iSWAP gripper. + + Args: + gripper_width: Plate width [mm]. + backend_params: iSWAP.CloseGripperParams with grip_strength and plate_width_tolerance. + """ + if not isinstance(backend_params, iSWAP.CloseGripperParams): + backend_params = iSWAP.CloseGripperParams() + + if not 0 <= backend_params.grip_strength <= 9: + raise ValueError("grip_strength must be between 0 and 9") + if not 0 <= gripper_width <= 999.9: + raise ValueError("gripper_width must be between 0 and 999.9") + if not 0 <= backend_params.plate_width_tolerance <= 9.9: + raise ValueError("plate_width_tolerance must be between 0 and 9.9") + + await self.interface.send_command( + module="C0", + command="GC", + gw=backend_params.grip_strength, + gb=f"{round(gripper_width * 10):04}", + gt=f"{round(backend_params.plate_width_tolerance * 10):02}", + ) + + @dataclass + class PickUpParams(BackendParams): + minimum_traverse_height: float = 280.0 + z_position_at_end: float = 280.0 + grip_strength: int = 4 + plate_width_tolerance: float = 2.0 + collision_control_level: int = 0 + acceleration_index_high_acc: int = 4 + acceleration_index_low_acc: int = 1 + fold_up_at_end: bool = False + + async def pick_up_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Pick up a plate at the specified location. + + Args: + location: Plate center position [mm]. + direction: Grip direction in degrees (0=front, 90=right, 180=back, 270=left). + resource_width: Plate width [mm]. + backend_params: iSWAP.PickUpParams for firmware-specific settings. + """ + if not isinstance(backend_params, iSWAP.PickUpParams): + backend_params = iSWAP.PickUpParams() + + open_gripper_position = resource_width + 3.0 + plate_width_for_firmware = round(resource_width * 10) - 33 + + if not 0 <= abs(location.x) <= 3000.0: + raise ValueError("x_position must be between -3000.0 and 3000.0") + if not 0 <= abs(location.y) <= 650.0: + raise ValueError("y_position must be between -650.0 and 650.0") + if not 0 <= abs(location.z) <= 360.0: + raise ValueError("z_position must be between -360.0 and 360.0") + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + if not 0 <= backend_params.z_position_at_end <= 360.0: + raise ValueError("z_position_at_end must be between 0 and 360.0") + if not 1 <= backend_params.grip_strength <= 9: + raise ValueError("grip_strength must be between 1 and 9") + if not 0 <= open_gripper_position <= 999.9: + raise ValueError("open_gripper_position must be between 0 and 999.9") + if not 0 <= resource_width <= 999.9: + raise ValueError("resource_width must be between 0 and 999.9") + if not 0 <= backend_params.plate_width_tolerance <= 9.9: + raise ValueError("plate_width_tolerance must be between 0 and 9.9") + if not 0 <= backend_params.collision_control_level <= 1: + raise ValueError("collision_control_level must be 0 or 1") + if not 0 <= backend_params.acceleration_index_high_acc <= 4: + raise ValueError("acceleration_index_high_acc must be between 0 and 4") + if not 0 <= backend_params.acceleration_index_low_acc <= 4: + raise ValueError("acceleration_index_low_acc must be between 0 and 4") + + grip_dir = _direction_degrees_to_grip_direction(direction) + + await self.interface.send_command( + module="C0", + command="PP", + xs=f"{abs(round(location.x * 10)):05}", + xd=int(location.x < 0), + yj=f"{abs(round(location.y * 10)):04}", + yd=int(location.y < 0), + zj=f"{abs(round(location.z * 10)):04}", + zd=int(location.z < 0), + gr=grip_dir, + th=f"{round(backend_params.minimum_traverse_height * 10):04}", + te=f"{round(backend_params.z_position_at_end * 10):04}", + gw=backend_params.grip_strength, + go=f"{round(open_gripper_position * 10):04}", + gb=f"{plate_width_for_firmware:04}", + gt=f"{round(backend_params.plate_width_tolerance * 10):02}", + ga=backend_params.collision_control_level, + gc=backend_params.fold_up_at_end, + ) + self._parked = False + + @dataclass + class DropParams(BackendParams): + minimum_traverse_height: float = 280.0 + z_position_at_end: float = 280.0 + collision_control_level: int = 0 + acceleration_index_high_acc: int = 4 + acceleration_index_low_acc: int = 1 + fold_up_at_end: bool = False + + async def drop_at_location( + self, + location: Coordinate, + direction: float, + resource_width: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Drop a plate at the specified location. + + Args: + location: Plate center position [mm]. + direction: Grip direction in degrees (0=front, 90=right, 180=back, 270=left). + resource_width: Plate width [mm]. Used to compute open gripper position. + backend_params: iSWAP.DropParams for firmware-specific settings. + """ + if not isinstance(backend_params, iSWAP.DropParams): + backend_params = iSWAP.DropParams() + + open_gripper_position = resource_width + 3.0 + + if not 0 <= abs(location.x) <= 3000.0: + raise ValueError("x_position must be between -3000.0 and 3000.0") + if not 0 <= abs(location.y) <= 650.0: + raise ValueError("y_position must be between -650.0 and 650.0") + if not 0 <= abs(location.z) <= 360.0: + raise ValueError("z_position must be between -360.0 and 360.0") + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + if not 0 <= backend_params.z_position_at_end <= 360.0: + raise ValueError("z_position_at_end must be between 0 and 360.0") + if not 0 <= open_gripper_position <= 999.9: + raise ValueError("open_gripper_position must be between 0 and 999.9") + if not 0 <= backend_params.collision_control_level <= 1: + raise ValueError("collision_control_level must be 0 or 1") + if not 0 <= backend_params.acceleration_index_high_acc <= 4: + raise ValueError("acceleration_index_high_acc must be between 0 and 4") + if not 0 <= backend_params.acceleration_index_low_acc <= 4: + raise ValueError("acceleration_index_low_acc must be between 0 and 4") + + grip_dir = _direction_degrees_to_grip_direction(direction) + + await self.interface.send_command( + module="C0", + command="PR", + xs=f"{abs(round(location.x * 10)):05}", + xd=int(location.x < 0), + yj=f"{abs(round(location.y * 10)):04}", + yd=int(location.y < 0), + zj=f"{abs(round(location.z * 10)):04}", + zd=int(location.z < 0), + th=f"{round(backend_params.minimum_traverse_height * 10):04}", + te=f"{round(backend_params.z_position_at_end * 10):04}", + gr=grip_dir, + go=f"{round(open_gripper_position * 10):04}", + ga=backend_params.collision_control_level, + gc=backend_params.fold_up_at_end, + ) + self._parked = False + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + """Check if the iSWAP is holding a plate. + + Returns: + True if holding a plate, False otherwise. + """ + resp = await self.interface.send_command(module="C0", command="QP", fmt="ph#") + return resp is not None and resp["ph"] == 1 + + @dataclass + class MoveToLocationParams(BackendParams): + minimum_traverse_height: float = 360.0 + collision_control_level: int = 1 + acceleration_index_high_acc: int = 4 + acceleration_index_low_acc: int = 1 + + async def move_to_location( + self, + location: Coordinate, + direction: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Move a held plate to a new position without releasing it. + + Args: + location: Target plate center position [mm]. + direction: Grip direction in degrees (0=front, 90=right, 180=back, 270=left). + backend_params: iSWAP.MoveToLocationParams for firmware-specific settings. + """ + if not isinstance(backend_params, iSWAP.MoveToLocationParams): + backend_params = iSWAP.MoveToLocationParams() + + if not 0 <= abs(location.x) <= 3000.0: + raise ValueError("x_position must be between -3000.0 and 3000.0") + if not 0 <= abs(location.y) <= 650.0: + raise ValueError("y_position must be between -650.0 and 650.0") + if not 0 <= abs(location.z) <= 360.0: + raise ValueError("z_position must be between -360.0 and 360.0") + if not 0 <= backend_params.minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + if not 0 <= backend_params.collision_control_level <= 1: + raise ValueError("collision_control_level must be 0 or 1") + if not 0 <= backend_params.acceleration_index_high_acc <= 4: + raise ValueError("acceleration_index_high_acc must be between 0 and 4") + if not 0 <= backend_params.acceleration_index_low_acc <= 4: + raise ValueError("acceleration_index_low_acc must be between 0 and 4") + + grip_dir = _direction_degrees_to_grip_direction(direction) + + await self.interface.send_command( + module="C0", + command="PM", + xs=f"{abs(round(location.x * 10)):05}", + xd=int(location.x < 0), + yj=f"{abs(round(location.y * 10)):04}", + yd=int(location.y < 0), + zj=f"{abs(round(location.z * 10)):04}", + zd=int(location.z < 0), + gr=grip_dir, + th=f"{round(backend_params.minimum_traverse_height * 10):04}", + ga=backend_params.collision_control_level, + xe=f"{backend_params.acceleration_index_high_acc} {backend_params.acceleration_index_low_acc}", + ) + self._parked = False + + async def halt(self, backend_params: Optional[BackendParams] = None) -> None: + raise NotImplementedError("iSWAP halt not yet implemented") diff --git a/pylabrobot/hamilton/liquid_handlers/star/iswap_test.ipynb b/pylabrobot/hamilton/liquid_handlers/star/iswap_test.ipynb new file mode 100644 index 00000000000..c540015a33a --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/iswap_test.ipynb @@ -0,0 +1,292 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# iSWAP + OrientableArm: Simple Resource Move Test\n", + "\n", + "Tests the `iSWAP` backend through `OrientableArm` with real PLR resources and the real STAR firmware interface." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.arms.orientable_arm import OrientableArm\n", + "from pylabrobot.arms.standard import GripDirection\n", + "from pylabrobot.hamilton.liquid_handlers.star.iswap import iSWAP\n", + "from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import STARBackend\n", + "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n", + "from pylabrobot.resources.hamilton.hamilton_decks import STARLetDeck\n", + "from pylabrobot.resources.hamilton.plate_carriers import PLT_CAR_L5AC_A00" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Set up deck with carrier and plate" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Plate 'my_plate' is in: carrier-1\n", + "Plate absolute location: Coordinate(396.500, 167.500, 183.120)\n", + "Destination site: carrier-2\n", + "Destination location: Coordinate(396.500, 263.500, 186.150)\n" + ] + } + ], + "source": [ + "deck = STARLetDeck()\n", + "\n", + "carrier = PLT_CAR_L5AC_A00(\"carrier\")\n", + "deck.assign_child_resource(carrier, rails=14)\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(\"my_plate\")\n", + "carrier[1].assign_child_resource(plate)\n", + "\n", + "print(f\"Plate '{plate.name}' is in: {plate.parent.name}\")\n", + "print(f\"Plate absolute location: {plate.get_absolute_location()}\")\n", + "print(f\"Destination site: {carrier[2].name}\")\n", + "print(f\"Destination location: {carrier[2].get_absolute_location()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create iSWAP backend with real STAR interface" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-03-20 22:03:16,770 - pylabrobot.io.usb - INFO - Finding USB device...\n", + "2026-03-20 22:03:16,811 - pylabrobot.io.usb - INFO - Found USB device.\n", + "2026-03-20 22:03:16,817 - pylabrobot.io.usb - INFO - Found endpoints. \n", + "Write:\n", + " ENDPOINT 0x2: Bulk OUT ===============================\n", + " bLength : 0x7 (7 bytes)\n", + " bDescriptorType : 0x5 Endpoint\n", + " bEndpointAddress : 0x2 OUT\n", + " bmAttributes : 0x2 Bulk\n", + " wMaxPacketSize : 0x40 (64 bytes)\n", + " bInterval : 0x0 \n", + "Read:\n", + " ENDPOINT 0x81: Bulk IN ===============================\n", + " bLength : 0x7 (7 bytes)\n", + " bDescriptorType : 0x5 Endpoint\n", + " bEndpointAddress : 0x81 IN\n", + " bmAttributes : 0x2 Bulk\n", + " wMaxPacketSize : 0x40 (64 bytes)\n", + " bInterval : 0x0\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cmd C0RMid0001\n", + "cmd C0QMid0002\n", + "cmd C0QWid0003\n", + "cmd C0ZAid0004\n", + "cmd C0EVid0005\n", + "cmd C0RTid0006\n", + "cmd I0QWid0007\n", + "cmd P1VYid0008\n", + "cmd P2VYid0009\n", + "cmd P3VYid0010\n", + "cmd P4VYid0011\n", + "cmd P5VYid0012\n", + "cmd P6VYid0013\n", + "cmd P7VYid0014\n", + "cmd P8VYid0015\n", + "cmd P9VYid0016\n", + "cmd PAVYid0017\n", + "cmd PBVYid0018\n", + "cmd PCVYid0019\n", + "cmd C0IVid0020\n", + "cmd R0QWid0021\n", + "cmd I0XPid0022xp54\n", + "cmd C0PGid0023th2800\n", + "cmd H0QWid0024\n", + "cmd H0RFid0025\n", + "cmd H0QUid0026\n", + "cmd H0QGid0027\n", + "OrientableArm ready, iSWAP parked: False\n" + ] + } + ], + "source": [ + "star_backend = STARBackend()\n", + "star_backend.set_deck(deck)\n", + "await star_backend.setup()\n", + "\n", + "iswap_backend = iSWAP(interface=star_backend)\n", + "\n", + "arm = OrientableArm(backend=iswap_backend, reference_resource=deck)\n", + "print(f\"OrientableArm ready, iSWAP parked: {iswap_backend.parked}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Pick up plate from carrier[0]" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cmd C0PPid0030xs04604xd0yj2102yd0zj1923zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0\n", + "Picked up 'my_plate'\n" + ] + } + ], + "source": [ + "await arm.pick_up_resource(\n", + " plate,\n", + " direction=GripDirection.FRONT,\n", + " backend_params=iSWAP.PickUpParams(\n", + " minimum_traverse_height=280.0,\n", + " z_position_at_end=280.0,\n", + " grip_strength=4,\n", + " plate_width_tolerance=2.0,\n", + " collision_control_level=0,\n", + " fold_up_at_end=False,\n", + " ),\n", + ")\n", + "print(f\"Picked up '{plate.name}'\")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cmd C0PGid0029th2840\n" + ] + } + ], + "source": [ + "await arm.park()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Drop plate at carrier[2]" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cmd C0PRid0031xs04604xd0yj3062yd0zj1923zd0th2800te2800gr1go1308ga0gc0\n", + "Plate 'my_plate' is now in: carrier-2\n", + "Plate absolute location: Coordinate(396.500, 263.500, 183.120)\n" + ] + } + ], + "source": [ + "await arm.drop_resource(carrier[2], direction=GripDirection.FRONT)\n", + "print(f\"Plate '{plate.name}' is now in: {plate.parent.name}\")\n", + "print(f\"Plate absolute location: {plate.get_absolute_location()}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "await arm.pick_up_resource(\n", + " plate,\n", + " direction=GripDirection.LEFT,\n", + ")\n", + "await arm.drop_resource(carrier[2], direction=GripDirection.RIGHT)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Park and check state" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await arm.park()\n", + "print(f\"iSWAP parked: {iswap_backend.parked}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.15" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/pylabrobot/hamilton/liquid_handlers/star/iswap_tests.py b/pylabrobot/hamilton/liquid_handlers/star/iswap_tests.py new file mode 100644 index 00000000000..8dd85f91f76 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/iswap_tests.py @@ -0,0 +1,194 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.hamilton.liquid_handlers.star.iswap import iSWAP +from pylabrobot.resources import Coordinate + + +class TestiSWAPCommands(unittest.IsolatedAsyncioTestCase): + """Test that iSWAP methods produce the exact same firmware commands as the legacy + STARBackend equivalents.""" + + async def asyncSetUp(self): + self.mock_interface = MagicMock() + self.mock_interface.send_command = AsyncMock() + self.iswap = iSWAP(interface=self.mock_interface) + + async def test_pick_up_at_location(self): + """C0PPid0001xs03479xd0yj1142yd0zj1874zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0""" + await self.iswap.pick_up_at_location( + location=Coordinate(347.9, 114.2, 187.4), + direction=0.0, + resource_width=127.76, + ) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="PP", + xs="03479", + xd=0, + yj="1142", + yd=0, + zj="1874", + zd=0, + gr=1, + th="2800", + te="2800", + gw=4, + go="1308", + gb="1245", + gt="20", + ga=0, + gc=False, + ) + + async def test_pick_up_grip_direction_left(self): + """C0PPid0003xs10427xd0yj3286yd0zj2063zd0gr4th2800te2800gw4go1308gb1245gt20ga0gc0""" + await self.iswap.pick_up_at_location( + location=Coordinate(1042.7, 328.6, 206.3), + direction=270.0, + resource_width=127.76, + ) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="PP", + xs="10427", + xd=0, + yj="3286", + yd=0, + zj="2063", + zd=0, + gr=4, + th="2800", + te="2800", + gw=4, + go="1308", + gb="1245", + gt="20", + ga=0, + gc=False, + ) + + async def test_drop_at_location(self): + """C0PRid0002xs03479xd0yj3062yd0zj1874zd0th2800te2800gr1go1308ga0gc0""" + await self.iswap.drop_at_location( + location=Coordinate(347.9, 306.2, 187.4), + direction=0.0, + resource_width=127.76, + ) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="PR", + xs="03479", + xd=0, + yj="3062", + yd=0, + zj="1874", + zd=0, + th="2800", + te="2800", + gr=1, + go="1308", + ga=0, + gc=False, + ) + + async def test_drop_grip_direction_left(self): + """C0PRid0002xs10427xd0yj3286yd0zj2063zd0th2800te2800gr4go1308ga0gc0""" + await self.iswap.drop_at_location( + location=Coordinate(1042.7, 328.6, 206.3), + direction=270.0, + resource_width=127.76, + ) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="PR", + xs="10427", + xd=0, + yj="3286", + yd=0, + zj="2063", + zd=0, + th="2800", + te="2800", + gr=4, + go="1308", + ga=0, + gc=False, + ) + + async def test_park(self): + await self.iswap.park() + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="PG", + th=2840, + ) + self.assertTrue(self.iswap.parked) + + async def test_park_custom_height(self): + await self.iswap.park(backend_params=iSWAP.ParkParams(minimum_traverse_height=200.0)) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="PG", + th=2000, + ) + + async def test_open_gripper(self): + await self.iswap.open_gripper(gripper_width=130.8) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="GF", + go="1308", + ) + + async def test_close_gripper(self): + await self.iswap.close_gripper( + gripper_width=86.0, + backend_params=iSWAP.CloseGripperParams(grip_strength=5, plate_width_tolerance=0), + ) + + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="GC", + gw=5, + gb="0860", + gt="00", + ) + + async def test_is_gripper_closed(self): + self.mock_interface.send_command.return_value = {"ph": 1} + result = await self.iswap.is_gripper_closed() + self.assertTrue(result) + self.mock_interface.send_command.assert_called_once_with( + module="C0", + command="QP", + fmt="ph#", + ) + + async def test_is_gripper_open(self): + self.mock_interface.send_command.return_value = {"ph": 0} + result = await self.iswap.is_gripper_closed() + self.assertFalse(result) + + async def test_parked_state_after_pick(self): + await self.iswap.pick_up_at_location( + location=Coordinate(100, 100, 100), + direction=0.0, + resource_width=80.0, + ) + self.assertFalse(self.iswap.parked) + + async def test_parked_state_after_drop(self): + await self.iswap.drop_at_location( + location=Coordinate(100, 100, 100), + direction=0.0, + resource_width=80.0, + ) + self.assertFalse(self.iswap.parked) diff --git a/pylabrobot/legacy/arms/precise_flex/pf_3400.py b/pylabrobot/legacy/arms/precise_flex/pf_3400.py index 5b35474576b..8095ef2fc36 100644 --- a/pylabrobot/legacy/arms/precise_flex/pf_3400.py +++ b/pylabrobot/legacy/arms/precise_flex/pf_3400.py @@ -1,8 +1,14 @@ +"""Legacy. Use pylabrobot.brooks.PreciseFlex3400Backend instead.""" + +from pylabrobot.brooks.precise_flex import PreciseFlex3400Backend as _NewBackend +from pylabrobot.brooks.precise_flex import PreciseFlexDriver from pylabrobot.legacy.arms.precise_flex.precise_flex_backend import PreciseFlexBackend class PreciseFlex3400Backend(PreciseFlexBackend): - """Backend for the PreciseFlex 3400 robotic arm.""" + """Legacy. Use pylabrobot.brooks.PreciseFlex3400Backend instead.""" def __init__(self, host: str, port: int = 10100, has_rail: bool = False, timeout=20) -> None: super().__init__(host=host, port=port, has_rail=has_rail, timeout=timeout) + self._new_driver = PreciseFlexDriver(host=host, port=port, timeout=timeout) + self._new_backend = _NewBackend(driver=self._new_driver, has_rail=has_rail) diff --git a/pylabrobot/legacy/arms/precise_flex/pf_400.py b/pylabrobot/legacy/arms/precise_flex/pf_400.py index b0a5ec0d837..778191821ab 100644 --- a/pylabrobot/legacy/arms/precise_flex/pf_400.py +++ b/pylabrobot/legacy/arms/precise_flex/pf_400.py @@ -1,8 +1,13 @@ +"""Legacy. Use pylabrobot.brooks.PreciseFlex400 instead.""" + +from pylabrobot.brooks.precise_flex import PreciseFlexArmBackend, PreciseFlexDriver from pylabrobot.legacy.arms.precise_flex.precise_flex_backend import PreciseFlexBackend class PreciseFlex400Backend(PreciseFlexBackend): - """Backend for the PreciseFlex 400 robotic arm.""" + """Legacy. Use pylabrobot.brooks.PreciseFlex400 instead.""" def __init__(self, host: str, port: int = 10100, has_rail: bool = False, timeout=20) -> None: super().__init__(host=host, port=port, has_rail=has_rail, timeout=timeout) + self._new_driver = PreciseFlexDriver(host=host, port=port, timeout=timeout) + self._new_backend = PreciseFlexArmBackend(driver=self._new_driver, has_rail=has_rail) diff --git a/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py index afba04dfe69..1ad3081173f 100644 --- a/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py +++ b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py @@ -1,8 +1,10 @@ -import asyncio +"""Legacy. Use pylabrobot.brooks instead.""" + import warnings from abc import ABC -from typing import Dict, List, Literal, Optional, Union +from typing import Dict, List, Optional, Union +from pylabrobot.brooks import precise_flex as _new_module from pylabrobot.io.socket import Socket from pylabrobot.legacy.arms.backend import ( AccessPattern, @@ -11,31 +13,60 @@ VerticalAccess, ) from pylabrobot.legacy.arms.precise_flex.coords import ElbowOrientation, PreciseFlexCartesianCoords -from pylabrobot.legacy.arms.precise_flex.error_codes import ERROR_CODES -from pylabrobot.legacy.arms.precise_flex.joints import PFAxis from pylabrobot.resources import Coordinate, Rotation +PreciseFlexError = _new_module.PreciseFlexError -class PreciseFlexError(Exception): - def __init__(self, replycode: int, message: str): - self.replycode = replycode - self.message = message - # Map error codes to text and descriptions - error_info = ERROR_CODES - if replycode in error_info: - text = error_info[replycode]["text"] - description = error_info[replycode]["description"] - super().__init__(f"PreciseFlexError {replycode}: {text}. {description} - {message}") - else: - super().__init__(f"PreciseFlexError {replycode}: {message}") +def _to_new_coords( + position: Union[PreciseFlexCartesianCoords, Dict[int, float]], +) -> Union[_new_module.PreciseFlexGripperLocation, Dict[int, float]]: + """Convert legacy CartesianCoords to new module's CartesianCoords.""" + if isinstance(position, PreciseFlexCartesianCoords): + return _new_module.PreciseFlexGripperLocation( + location=position.location, + rotation=position.rotation, + orientation=position.orientation.value if position.orientation is not None else None, + ) + return position -class PreciseFlexBackend(SCARABackend, ABC): - """Backend for the PreciseFlex robotic arm - Default to using Cartesian coordinates, some methods in Brook's TCS don't work with Joint coordinates. +def _to_new_access(access: Optional[AccessPattern]) -> Optional[_new_module.AccessPattern]: + """Convert legacy AccessPattern to new module's AccessPattern.""" + if access is None: + return None + if isinstance(access, VerticalAccess): + return _new_module.VerticalAccess( + approach_height_mm=access.approach_height_mm, + clearance_mm=access.clearance_mm, + gripper_offset_mm=access.gripper_offset_mm, + ) + if isinstance(access, HorizontalAccess): + return _new_module.HorizontalAccess( + approach_distance_mm=access.approach_distance_mm, + clearance_mm=access.clearance_mm, + lift_height_mm=access.lift_height_mm, + gripper_offset_mm=access.gripper_offset_mm, + ) + return None + + +def _from_new_coords( + position: _new_module.PreciseFlexGripperLocation, +) -> PreciseFlexCartesianCoords: + """Convert new module's GripperLocation to legacy CartesianCoords.""" + orientation = None + if position.orientation is not None: + orientation = ElbowOrientation(position.orientation) + return PreciseFlexCartesianCoords( + location=position.location, + rotation=position.rotation, + orientation=orientation, + ) - Documentation and error codes available at https://www2.brooksautomation.com/#Root/Welcome.htm - """ + +class PreciseFlexBackend(SCARABackend, ABC): + """Legacy. Use pylabrobot.brooks.PreciseFlexArmBackend instead.""" def __init__( self, @@ -46,6 +77,11 @@ def __init__( timeout=20, ) -> None: super().__init__() + self._new_driver = _new_module.PreciseFlexDriver(host=host, port=port, timeout=timeout) + self._new_backend = _new_module.PreciseFlexArmBackend( + driver=self._new_driver, is_dual_gripper=is_dual_gripper, has_rail=has_rail + ) + # Keep these for any legacy code that accesses them directly self.io = Socket(human_readable_device_name="Precise Flex Arm", host=host, port=port) self.profile_index: int = 1 self.location_index: int = 1 @@ -62,7 +98,6 @@ def __init__( def _convert_to_cartesian_space( self, position: tuple[float, float, float, float, float, float, Optional[ElbowOrientation]] ) -> PreciseFlexCartesianCoords: - """Convert a tuple of cartesian coordinates to a CartesianCoords object.""" if len(position) != 7: raise ValueError( "Position must be a tuple of 7 values (x, y, z, yaw, pitch, roll, orientation)." @@ -77,7 +112,6 @@ def _convert_to_cartesian_space( def _convert_to_cartesian_array( self, position: PreciseFlexCartesianCoords ) -> tuple[float, float, float, float, float, float, int]: - """Convert a CartesianCoords object to a list of cartesian coordinates.""" orientation_int = self._convert_orientation_enum_to_int(position.orientation) arr = ( position.location.x, @@ -91,59 +125,31 @@ def _convert_to_cartesian_array( return arr async def setup(self, skip_home: bool = False): - """Initialize the PreciseFlex backend.""" - await self.io.setup() - await self.set_response_mode("pc") - await self.power_on_robot() - await self.attach(1) - if not skip_home: - await self.home() + await self._new_driver.setup(skip_home=skip_home) async def stop(self): - """Stop the PreciseFlex backend.""" - await self.detach() - await self.power_off_robot() - await self.exit() - await self.io.stop() + await self._new_driver.stop() async def set_speed(self, speed_percent: float): - """Set the speed percentage of the arm's movement (0-100).""" - await self.set_profile_speed(self.profile_index, speed_percent) + await self._new_backend._set_speed(speed_percent) async def get_speed(self) -> float: - """Get the current speed percentage of the arm's movement.""" - return await self.get_profile_speed(self.profile_index) + return await self._new_backend._get_speed() async def open_gripper(self, gripper_width: float): - """Open the gripper to the specified width. If no width is specified, opens to the default open position.""" - await self._set_grip_open_pos(gripper_width) - await self.send_command("gripper 1") + await self._new_backend.open_gripper(gripper_width) async def close_gripper(self, gripper_width: float): - """Close the gripper to the specified width. If no width is specified, closes to the default close position.""" - await self._set_grip_close_pos(gripper_width) - await self.send_command("gripper 2") + await self._new_backend.close_gripper(gripper_width) async def halt(self): - """Stops the current robot immediately but leaves power on.""" - await self.send_command("halt") + await self._new_backend.halt() async def home(self) -> None: - """Home the robot associated with this thread. - - Note: - Requires power to be enabled. - Requires robot to be attached. - Waits until the homing is complete. - """ - await self.send_command("home") + await self._new_driver.home() async def move_to_safe(self) -> None: - """Moves the robot to Safe Position. - - Does not include checks for collision with 3rd party obstacles inside the work volume of the robot. - """ - await self.send_command("movetosafe") + await self._new_backend.move_to_safe() def _convert_orientation_int_to_enum(self, orientation_int: int) -> Optional[ElbowOrientation]: if orientation_int == 1: @@ -160,78 +166,26 @@ def _convert_orientation_enum_to_int(self, orientation: Optional[ElbowOrientatio return 0 async def home_all(self) -> None: - """Home all robots. - - Note: - Requires power to be enabled. - Requires that robots not be attached. - """ - await self.send_command("homeAll") + await self._new_driver.home_all() async def attach(self, attach_state: Optional[int] = None) -> int: - """Attach or release the robot, or get attachment state. - - Args: - attach_state: If omitted, returns the attachment state. 0 = release the robot; 1 = attach the robot. - - Returns: - If attach_state is omitted, returns 0 if robot is not attached, -1 if attached. Otherwise returns 0 on success. - - Note: - The robot must be attached to allow motion commands. - """ - if attach_state is None: - response = await self.send_command("attach") - return int(response) - await self.send_command(f"attach {attach_state}") - return 0 + return await self._new_driver.attach(attach_state) async def detach(self): - """Detach the robot.""" - await self.attach(0) + await self._new_driver.detach() async def power_on_robot(self): - """Power on the robot.""" - await self.set_power(True, self.timeout) + await self._new_driver.power_on_robot() async def power_off_robot(self): - """Power off the robot.""" - await self.set_power(False) + await self._new_driver.power_off_robot() async def approach( self, position: Union[PreciseFlexCartesianCoords, Dict[int, float]], access: Optional[AccessPattern] = None, ): - """Move the arm to an approach position (offset from target). - - Args: - position: Target position (CartesianCoords or Dict[int, float]) - access: Access pattern defining how to approach the target. Defaults to VerticalAccess() if not specified. - - Example: - # Simple vertical approach (default) - await backend.approach(position) - - # Horizontal hotel-style approach - await backend.approach( - position, - HorizontalAccess( - approach_distance_mm=50, - clearance_mm=50, - lift_height_mm=100 - ) - ) - """ - if access is None: - access = VerticalAccess() - - if isinstance(position, dict): - await self._approach_j(position, access) - elif isinstance(position, PreciseFlexCartesianCoords): - await self._approach_c(position, access) - else: - raise TypeError("Position must be of type Dict[int, float] or CartesianCoords.") + await self._new_backend.approach(_to_new_coords(position), _to_new_access(access)) async def pick_up_resource( self, @@ -241,1493 +195,351 @@ async def pick_up_resource( finger_speed_percent: float = 50.0, grasp_force: float = 10.0, ): - """Pick a plate from the specified position. - - Args: - position: Target position for pickup (CartesianCoords only, joint coords not supported) - plate_width: Gripper width in millimeters used when gripping the plate. - access: How to access the location (VerticalAccess or HorizontalAccess). Defaults to VerticalAccess() if not specified. - finger_speed_percent: Speed percentage for the gripper fingers (1-100) - grasp_force: Grasp force in Newtons - - Raises: - ValueError: If position is not CartesianCoords - - Example: - # Simple vertical pick (default) - await backend.pick_plate(position) - - # Vertical pick with custom clearance - await backend.pick_plate(position, VerticalAccess(clearance_mm=150)) - - # Horizontal hotel-style pick - await backend.pick_plate( - position, - HorizontalAccess( - approach_distance_mm=50, - clearance_mm=50, - lift_height_mm=100 - ) - ) - """ - if access is None: - access = VerticalAccess() - - await self.set_grasp_data( - plate_width=plate_width, + converted = _to_new_coords(position) + params = _new_module.PreciseFlexArmBackend.PickUpParams( + access=_to_new_access(access), finger_speed_percent=finger_speed_percent, grasp_force=grasp_force, ) - - if isinstance(position, PreciseFlexCartesianCoords): - await self._pick_plate_c(cartesian_position=position, access=access) - elif isinstance(position, dict): - await self._pick_plate_j(position, access) + if isinstance(converted, dict): + await self._new_backend.pick_up_at_joint_position( + converted, plate_width, backend_params=params + ) else: - raise TypeError("Position must be of type Dict[int, float] or CartesianCoords.") + await self._new_backend.pick_up_at_location( + converted.location, converted.rotation.z, plate_width, backend_params=params + ) async def drop_resource( self, position: Union[PreciseFlexCartesianCoords, Dict[int, float]], access: Optional[AccessPattern] = None, ): - """Place a plate at the specified position. - - Args: - position: Target position for placement (CartesianCoords only, joint coords not supported) - access: How to access the location (VerticalAccess or HorizontalAccess). Defaults to VerticalAccess() if not specified. - - Raises: - ValueError: If position is not CartesianCoords - - Example: - # Simple vertical place (default) - await backend.place_plate(position) - - # Vertical place with custom clearance - await backend.place_plate(position, VerticalAccess(clearance_mm=150)) - - # Horizontal hotel-style place - await backend.place_plate( - position, - HorizontalAccess( - approach_distance_mm=50, - clearance_mm=50, - lift_height_mm=100 - ) + converted = _to_new_coords(position) + params = _new_module.PreciseFlexArmBackend.DropParams(access=_to_new_access(access)) + if isinstance(converted, dict): + await self._new_backend.drop_at_joint_position( + converted, resource_width=0, backend_params=params + ) + else: + await self._new_backend.drop_at_location( + converted.location, converted.rotation.z, resource_width=0, backend_params=params ) - """ - if access is None: - access = VerticalAccess() - - if not isinstance(position, PreciseFlexCartesianCoords): - raise TypeError("place_plate only supports CartesianCoords for PreciseFlex.") - await self._place_plate_c(cartesian_position=position, access=access) async def move_to(self, position: Union[PreciseFlexCartesianCoords, Dict[int, float]]): - """Move the arm to a specified position in 3D space. - - Args: - position: Either CartesianCoords or a dict mapping PFAxis to float values. - When using a dict, any unspecified axes will be filled in from the current position. - """ - print(position, isinstance(position, dict)) - if isinstance(position, dict): - current = await self.get_joint_position() - joint_coords = {**current, **position} - await self.move_j(profile_index=self.profile_index, joint_coords=joint_coords) - elif isinstance(position, PreciseFlexCartesianCoords): - await self.move_c(profile_index=self.profile_index, cartesian_coords=position) + converted = _to_new_coords(position) + if isinstance(converted, dict): + await self._new_backend.move_to_joint_position(converted) else: - raise TypeError("Position must be of type Dict[int, float] or CartesianCoords.") + await self._new_backend.move_to_location(converted.location, converted.rotation.z) async def get_joint_position(self) -> Dict[int, float]: - """Get the current joint position of the arm.""" - - await self.wait_for_eom() - - num_tries = 2 - for _ in range(num_tries): - data = await self.send_command("wherej") - parts = data.split() - if len(parts) > 0: - break - else: - raise PreciseFlexError(-1, "Unexpected response format from wherej command.") - - return self._parse_angles_response(parts) + return await self._new_backend.get_joint_position() async def get_cartesian_position(self) -> PreciseFlexCartesianCoords: - """Get the current position of the arm in 3D space.""" - - await self.wait_for_eom() - - num_tries = 2 - for _ in range(num_tries): - data = await self.send_command("wherec") - parts = data.split() - if len(parts) == 7: - break - else: - raise PreciseFlexError(-1, "Unexpected response format from wherec command.") - - x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts[0:6]) - config = int(parts[6]) - - # return (x, y, z, yaw, pitch, roll, config) - enum_thing = self._convert_orientation_int_to_enum(config) - - return self._convert_to_cartesian_space(position=(x, y, z, yaw, pitch, roll, enum_thing)) + result = await self._new_backend.get_gripper_location() + return _from_new_coords(result) async def send_command(self, command: str) -> str: - await self.io.write(command.encode("utf-8") + b"\n") - reply = await self.io.readline() - - return self._parse_reply_ensure_successful(reply) + return await self._new_driver.send_command(command) def _parse_reply_ensure_successful(self, reply: bytes) -> str: - """Parse reply from Precise Flex. - - Expected format: b'replycode data message\r\n' - - replycode is an integer at the beginning - - data is rest of the line (excluding CRLF) - """ - text = reply.decode().strip() # removes \r\n - if not text: - raise PreciseFlexError(-1, "Empty reply from device.") - - parts = text.split(" ", 1) - if len(parts) == 1: - replycode = int(parts[0]) - data = "" - else: - replycode, data = int(parts[0]), parts[1] - - if replycode != 0: - # if error is reported, the data part generally contains the error message - raise PreciseFlexError(replycode, data) - - return data - - async def _approach_j(self, joint_position: Dict[int, float], access: AccessPattern): - """Move the arm to a position above the specified coordinates. - - The approach behavior depends on the access pattern: - - VerticalAccess: Approaches from above using approach_height_mm - - HorizontalAccess: Approaches from the side using approach_distance_mm - """ - await self.set_joint_angles(self.location_index, joint_position) - await self._set_grip_detail(access) - await self.move_to_stored_location_appro(self.location_index, self.profile_index) - - async def _pick_plate_j(self, joint_position: Dict[int, float], access: AccessPattern): - """Pick a plate from the specified position using joint coordinates.""" - await self.set_joint_angles(self.location_index, joint_position) - await self._set_grip_detail(access) - await self.pick_plate_from_stored_position( - self.location_index, self.horizontal_compliance, self.horizontal_compliance_torque - ) + return self._new_driver._parse_reply_ensure_successful(reply) - async def _place_plate_j(self, joint_position: Dict[int, float], access: AccessPattern): - """Place a plate at the specified position using joint coordinates.""" - await self.set_joint_angles(self.location_index, joint_position) - await self._set_grip_detail(access) - await self.place_plate_to_stored_position( - self.location_index, self.horizontal_compliance, self.horizontal_compliance_torque - ) - - async def _approach_c( - self, - cartesian_position: PreciseFlexCartesianCoords, - access: AccessPattern, - ): - """Move the arm to a position above the specified coordinates. - - The approach behavior depends on the access pattern: - - VerticalAccess: Approaches from above using approach_height_mm - - HorizontalAccess: Approaches from the side using approach_distance_mm - """ - await self.set_location_xyz(self.location_index, cartesian_position) - await self._set_grip_detail(access) - orientation_int = self._convert_orientation_enum_to_int(cartesian_position.orientation) - await self.set_location_config(self.location_index, orientation_int) - await self.move_to_stored_location_appro(self.location_index, self.profile_index) - - async def _pick_plate_c( - self, - cartesian_position: PreciseFlexCartesianCoords, - access: AccessPattern, - ): - """Pick a plate from the specified position using Cartesian coordinates.""" - await self.set_location_xyz(self.location_index, cartesian_position) - await self._set_grip_detail(access) - orientation_int = self._convert_orientation_enum_to_int(cartesian_position.orientation) - orientation_int |= 0x1000 # GPL_Single: restrict wrist to ±180° - await self.set_location_config(self.location_index, orientation_int) - await self.pick_plate_from_stored_position( - self.location_index, self.horizontal_compliance, self.horizontal_compliance_torque - ) - - async def _place_plate_c( - self, - cartesian_position: PreciseFlexCartesianCoords, - access: AccessPattern, - ): - """Place a plate at the specified position using Cartesian coordinates.""" - await self.set_location_xyz(self.location_index, cartesian_position) - await self._set_grip_detail(access) - orientation_int = self._convert_orientation_enum_to_int(cartesian_position.orientation) - orientation_int |= 0x1000 # GPL_Single: restrict wrist to ±180° - await self.set_location_config(self.location_index, orientation_int) - await self.place_plate_to_stored_position( - self.location_index, self.horizontal_compliance, self.horizontal_compliance_torque - ) - - async def _set_grip_detail(self, access: AccessPattern): - """Configure station type for pick/place operations based on access pattern. - - Calls TCS set_station_type command to configure how the robot interprets - clearance values and performs approach/retract motions. - - Args: - access: Access pattern (VerticalAccess or HorizontalAccess) defining how to approach and retract from the location. - """ - if isinstance(access, VerticalAccess): - # Vertical access: access_type=1, z_clearance is vertical distance - await self.set_station_type( - station_id=self.location_index, - access_type=1, - location_type=0, - z_clearance=access.clearance_mm, - z_above=0, - z_grasp_offset=access.gripper_offset_mm, - ) - elif isinstance(access, HorizontalAccess): - # Horizontal access: access_type=0, z_clearance is horizontal distance - await self.set_station_type( - station_id=self.location_index, - access_type=0, - location_type=0, - z_clearance=access.clearance_mm, - z_above=access.lift_height_mm, - z_grasp_offset=access.gripper_offset_mm, - ) - else: - raise TypeError("Access pattern must be VerticalAccess or HorizontalAccess.") - - # region GENERAL COMMANDS - - async def get_base(self) -> tuple[float, float, float, float]: - """Get the robot base offset. + async def is_gripper_closed(self) -> bool: + return await self._new_backend.is_gripper_closed() - Returns: - A tuple containing (x_offset, y_offset, z_offset, z_rotation) - """ - data = await self.send_command("base") - parts = data.split() - if len(parts) != 4: - raise PreciseFlexError(-1, "Unexpected response format from base command.") + async def are_grippers_closed(self) -> tuple[bool, bool]: + return await self._new_backend.are_grippers_closed() - x_offset = float(parts[0]) - y_offset = float(parts[1]) - z_offset = float(parts[2]) - z_rotation = float(parts[3]) + async def freedrive_mode(self, free_axes: List[int]) -> None: + await self._new_backend.start_freedrive_mode(free_axes) - return (x_offset, y_offset, z_offset, z_rotation) + async def end_freedrive_mode(self) -> None: + await self._new_backend.stop_freedrive_mode() async def set_base( self, x_offset: float, y_offset: float, z_offset: float, z_rotation: float ) -> None: - """Set the robot base offset. + await self._new_backend.set_base(x_offset, y_offset, z_offset, z_rotation) - Args: - x_offset: Base X offset - y_offset: Base Y offset - z_offset: Base Z offset - z_rotation: Base Z rotation - - Note: - The robot must be attached to set the base. - Setting the base pauses any robot motion in progress. - """ - await self.send_command(f"base {x_offset} {y_offset} {z_offset} {z_rotation}") + async def get_base(self) -> tuple[float, float, float, float]: + return await self._new_backend.get_base() async def exit(self) -> None: - """Close the communications link immediately. - - Note: - Does not affect any robots that may be active. - """ - await self.io.write(b"exit\n") + await self._new_driver.exit() async def get_power_state(self) -> int: - """Get the current robot power state. - - Returns: - Current power state (0 = disabled, 1 = enabled) - """ - response = await self.send_command("hp") - return int(response) + return await self._new_driver.get_power_state() async def set_power(self, enable: bool, timeout: int = 0) -> None: - """Enable or disable robot high power. - - Args: - enable: True to enable power, False to disable - timeout: Wait timeout for power to come on. - 0 or omitted = do not wait for power to come on - > 0 = wait this many seconds for power to come on - -1 = wait indefinitely for power to come on - - Raises: - PreciseFlexError: If power does not come on within the specified timeout. - """ - power_state = 1 if enable else 0 - if timeout == 0: - await self.send_command(f"hp {power_state}") - else: - await self.send_command(f"hp {power_state} {timeout}") - - ResponseMode = Literal["pc", "verbose"] - - async def get_mode(self) -> ResponseMode: - """Get the current response mode. - - Returns: - Current mode (0 = PC mode, 1 = verbose mode) - """ - response = await self.send_command("mode") - mapping: Dict[int, PreciseFlexBackend.ResponseMode] = { - 0: "pc", - 1: "verbose", - } - return mapping[int(response)] - - async def set_response_mode(self, mode: ResponseMode) -> None: - """Set the response mode. - - Args: - mode: Response mode to set. - 0 = Select PC mode - 1 = Select verbose mode - - Note: - When using serial communications, the mode change does not take effect - until one additional command has been processed. - """ - if mode not in ["pc", "verbose"]: - raise ValueError("Mode must be 'pc' or 'verbose'") - mapping = { - "pc": 0, - "verbose": 1, - } - await self.send_command(f"mode {mapping[mode]}") + await self._new_driver.set_power(enable, timeout) - async def get_monitor_speed(self) -> int: - """Get the global system (monitor) speed. + async def get_mode(self): + return await self._new_driver.get_mode() - Returns: - Current monitor speed as a percentage (1-100) - """ - response = await self.send_command("mspeed") - return int(response) + async def set_response_mode(self, mode) -> None: + await self._new_driver.set_response_mode(mode) - async def set_monitor_speed(self, speed_percent: int) -> None: - """Set the global system (monitor) speed. - - Args: - speed_percent: Speed percentage between 1 and 100, where 100 means full speed. + async def get_monitor_speed(self) -> int: + return await self._new_backend.get_monitor_speed() - Raises: - ValueError: If speed_percent is not between 1 and 100. - """ - if not (1 <= speed_percent <= 100): - raise ValueError("Speed percent must be between 1 and 100") - await self.send_command(f"mspeed {speed_percent}") + async def set_monitor_speed(self, speed_percent: int) -> None: + await self._new_backend.set_monitor_speed(speed_percent) async def nop(self) -> None: - """No operation command. - - Does nothing except return the standard reply. Can be used to see if the link - is active or to check for exceptions. - """ - await self.send_command("nop") + await self._new_backend.nop() async def get_payload(self) -> int: - """Get the payload percent value for the current robot. - - Returns: - Current payload as a percentage of maximum (0-100) - """ - response = await self.send_command("payload") - return int(response) + return await self._new_backend.get_payload() async def set_payload(self, payload_percent: int) -> None: - """Set the payload percent of maximum for the currently selected or attached robot. - - Args: - payload_percent: Payload percentage from 0 to 100 indicating the percent of the maximum payload the robot is carrying. - - Raises: - ValueError: If payload_percent is not between 0 and 100. - - Note: - If the robot is moving, waits for the robot to stop before setting a value. - """ - if not (0 <= payload_percent <= 100): - raise ValueError("Payload percent must be between 0 and 100") - await self.send_command(f"payload {payload_percent}") + await self._new_backend.set_payload(payload_percent) - async def set_parameter( - self, - data_id: int, - value, - unit_number: Optional[int] = None, - sub_unit: Optional[int] = None, - array_index: Optional[int] = None, - ) -> None: - """Change a value in the controller's parameter database. - - Args: - data_id: DataID of parameter. - value: New parameter value. If string, will be quoted automatically. - unit_number: Unit number, usually the robot number (1 - N_ROB). - sub_unit: Sub-unit, usually 0. - array_index: Array index. - - Note: - Updated values are not saved in flash unless a save-to-flash operation - is performed (see DataID 901). - """ - if unit_number is not None and sub_unit is not None and array_index is not None: - # 5 argument format - if isinstance(value, str): - await self.send_command(f'pc {data_id} {unit_number} {sub_unit} {array_index} "{value}"') - else: - await self.send_command(f"pc {data_id} {unit_number} {sub_unit} {array_index} {value}") - else: - # 2 argument format - if isinstance(value, str): - await self.send_command(f'pc {data_id} "{value}"') - else: - await self.send_command(f"pc {data_id} {value}") + async def set_parameter(self, data_id, value, unit_number=None, sub_unit=None, array_index=None): + await self._new_backend.set_parameter(data_id, value, unit_number, sub_unit, array_index) - async def get_parameter( - self, - data_id: int, - unit_number: Optional[int] = None, - sub_unit: Optional[int] = None, - array_index: Optional[int] = None, - ) -> str: - """Get the value of a numeric parameter database item. - - Args: - data_id: DataID of parameter. - unit_number: Unit number, usually the robot number (1-NROB). - sub_unit: Sub-unit, usually 0. - array_index: Array index. - - Returns: - str: The numeric value of the specified database parameter. - """ - if unit_number is not None: - if sub_unit is not None: - if array_index is not None: - response = await self.send_command(f"pd {data_id} {unit_number} {sub_unit} {array_index}") - else: - response = await self.send_command(f"pd {data_id} {unit_number} {sub_unit}") - else: - response = await self.send_command(f"pd {data_id} {unit_number}") - else: - response = await self.send_command(f"pd {data_id}") - return response + async def get_parameter(self, data_id, unit_number=None, sub_unit=None, array_index=None): + return await self._new_backend.get_parameter(data_id, unit_number, sub_unit, array_index) async def reset(self, robot_number: int) -> None: - """Reset the threads associated with the specified robot. - - Stops and restarts the threads for the specified robot. Any TCP/IP connections - made by these threads are broken. This command can only be sent to the status thread. - - Args: - robot_number: The number of the robot thread to reset, from 1 to N_ROB. Must not be zero. - - Raises: - ValueError: If robot_number is zero or negative. - """ - if robot_number <= 0: - raise ValueError("Robot number must be greater than zero") - await self.send_command(f"reset {robot_number}") + await self._new_backend.reset(robot_number) async def get_selected_robot(self) -> int: - """Get the number of the currently selected robot. - - Returns: - The number of the currently selected robot. - """ - response = await self.send_command("selectRobot") - return int(response) + return await self._new_backend.get_selected_robot() async def select_robot(self, robot_number: int) -> None: - """Change the robot associated with this communications link. - - Does not affect the operation or attachment state of the robot. The status thread - may select any robot or 0. Except for the status thread, a robot may only be - selected by one thread at a time. - - Args: - robot_number: The new robot to be connected to this thread (1 to N_ROB) or 0 for none. - """ - await self.send_command(f"selectRobot {robot_number}") + await self._new_backend.select_robot(robot_number) async def get_signal(self, signal_number: int) -> int: - """Get the value of the specified digital input or output signal. - - Args: - signal_number: The number of the digital signal to get. - - Returns: - The current signal value. - """ - response = await self.send_command(f"sig {signal_number}") - sig_id, sig_val = response.split() - return int(sig_val) + return await self._new_backend.get_signal(signal_number) async def set_signal(self, signal_number: int, value: int) -> None: - """Set the specified digital input or output signal. - - Args: - signal_number: The number of the digital signal to set. - value: The signal value to set. 0 = off, non-zero = on. - """ - await self.send_command(f"sig {signal_number} {value}") + await self._new_backend.set_signal(signal_number, value) async def get_system_state(self) -> int: - """Get the global system state code. - - Returns: - The global system state code. Please see documentation for DataID 234. - """ - response = await self.send_command("sysState") - return int(response) - - async def get_tool_transformation_values(self) -> tuple[float, float, float, float, float, float]: - """Get the current tool transformation values. - - Returns: - A tuple containing (X, Y, Z, yaw, pitch, roll) for the tool transformation. - """ - data = await self.send_command("tool") - # Remove "tool:" prefix if present - if data.startswith("tool: "): - data = data[6:] - - parts = data.split() - if len(parts) != 6: - raise PreciseFlexError(-1, "Unexpected response format from tool command.") - - x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts) - - return (x, y, z, yaw, pitch, roll) + return await self._new_backend.get_system_state() - async def set_tool_transformation_values( - self, x: float, y: float, z: float, yaw: float, pitch: float, roll: float - ) -> None: - """Set the robot tool transformation. - - The robot must be attached to set the tool. Setting the tool pauses any robot motion in progress. + async def get_tool_transformation_values(self): + return await self._new_backend.get_tool_transformation_values() - Args: - x: Tool X coordinate. - y: Tool Y coordinate. - z: Tool Z coordinate. - yaw: Tool yaw rotation. - pitch: Tool pitch rotation. - roll: Tool roll rotation. - """ - await self.send_command(f"tool {x} {y} {z} {yaw} {pitch} {roll}") + async def set_tool_transformation_values(self, x, y, z, yaw, pitch, roll): + await self._new_backend.set_tool_transformation_values(x, y, z, yaw, pitch, roll) async def get_version(self) -> str: - """Get the current version of TCS and any installed plug-ins. - - Returns: - str: The current version information. - """ - return await self.send_command("version") + return await self._new_backend.get_version() - # region LOCATION COMMANDS - - async def get_location_angles(self, location_index: int) -> tuple[int, int, Dict[int, float]]: - """Get the angle values for the specified station index. - - Args: - location_index: The station index, from 1 to N_LOC. - - Returns: - A tuple containing (type_code, station_index, angles_dict) - - Raises: - PreciseFlexError: If attempting to get angles from a Cartesian location. - """ + async def get_location_angles(self, location_index): data = await self.send_command(f"locAngles {location_index}") parts = data.split(" ") - type_code = int(parts[0]) if type_code != 1: - raise PreciseFlexError(-1, "Location is not of angles type.") - + raise _new_module.PreciseFlexError(-1, "Location is not of angles type.") station_index = int(parts[1]) angles = self._parse_angles_response(parts[2:]) - return (type_code, station_index, angles) - async def set_joint_angles( - self, - location_index: int, - joint_position: Dict[int, float], - ) -> None: - """Set joint angles for stored location, handling rail configuration.""" - if self._has_rail: - await self.send_command( - f"locAngles {location_index} " - f"{joint_position[PFAxis.RAIL]} " - f"{joint_position[PFAxis.BASE]} " - f"{joint_position[PFAxis.SHOULDER]} " - f"{joint_position[PFAxis.ELBOW]} " - f"{joint_position[PFAxis.WRIST]} " - f"{joint_position[PFAxis.GRIPPER]}" - ) - else: - await self.send_command( - f"locAngles {location_index} " - f"{joint_position[PFAxis.BASE]} " - f"{joint_position[PFAxis.SHOULDER]} " - f"{joint_position[PFAxis.ELBOW]} " - f"{joint_position[PFAxis.WRIST]} " - f"{joint_position[PFAxis.GRIPPER]}" - ) - - async def get_location_xyz( - self, location_index: int - ) -> tuple[int, int, float, float, float, float, float, float]: - """Get the Cartesian position values for the specified station index. - - Args: - location_index: The station index, from 1 to N_LOC. - - Returns: - A tuple containing (type_code, station_index, X, Y, Z, yaw, pitch, roll) + async def set_joint_angles(self, location_index, joint_position): + await self._new_backend._set_joint_angles(location_index, joint_position) - Raises: - PreciseFlexError: If attempting to get Cartesian position from an angles type location. - """ + async def get_location_xyz(self, location_index): data = await self.send_command(f"locXyz {location_index}") parts = data.split(" ") - type_code = int(parts[0]) if type_code != 0: - raise PreciseFlexError(-1, "Location is not of Cartesian type.") - + raise _new_module.PreciseFlexError(-1, "Location is not of Cartesian type.") if len(parts) != 8: - raise PreciseFlexError(-1, "Unexpected response format from locXyz command.") - + raise _new_module.PreciseFlexError(-1, "Unexpected response format from locXyz command.") station_index = int(parts[1]) x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts[2:8]) - return (type_code, station_index, x, y, z, yaw, pitch, roll) - async def set_location_xyz( - self, - location_index: int, - cartesian_position: PreciseFlexCartesianCoords, - ) -> None: - """Set the Cartesian position values for the specified station index. - - Args: - location_index: The station index, from 1 to N_LOC. - cartesian_position: The Cartesian position to set. - """ - await self.send_command( - f"locXyz {location_index} " - f"{cartesian_position.location.x} " - f"{cartesian_position.location.y} " - f"{cartesian_position.location.z} " - f"{cartesian_position.rotation.yaw} " - f"{cartesian_position.rotation.pitch} " - f"{cartesian_position.rotation.roll}" - ) - - async def get_location_z_clearance(self, location_index: int) -> tuple[int, float, bool]: - """Get the ZClearance and ZWorld properties for the specified location. - - Args: - location_index: The station index, from 1 to N_LOC. + async def set_location_xyz(self, location_index, cartesian_position): + converted = _to_new_coords(cartesian_position) + if not isinstance(converted, _new_module.PreciseFlexGripperLocation): + raise TypeError("Expected cartesian coordinates, got joint position dict") + await self._new_backend._set_location_xyz(location_index, converted) - Returns: - A tuple containing (station_index, z_clearance, z_world) - """ + async def get_location_z_clearance(self, location_index): data = await self.send_command(f"locZClearance {location_index}") parts = data.split(" ") - if len(parts) != 3: - raise PreciseFlexError(-1, "Unexpected response format from locZClearance command.") - + raise _new_module.PreciseFlexError( + -1, "Unexpected response format from locZClearance command." + ) station_index = int(parts[0]) z_clearance = float(parts[1]) - z_world = True if float(parts[2]) != 0 else False - + z_world = float(parts[2]) != 0 return (station_index, z_clearance, z_world) - async def set_location_z_clearance( - self, location_index: int, z_clearance: float, z_world: Optional[bool] = None - ) -> None: - """Set the ZClearance and ZWorld properties for the specified location. - - Args: - location_index: The station index, from 1 to N_LOC. - z_clearance: The new ZClearance property value. - z_world (float, optional): The new ZWorld property value. If omitted, only ZClearance is set. - """ + async def set_location_z_clearance(self, location_index, z_clearance, z_world=None): if z_world is None: await self.send_command(f"locZClearance {location_index} {z_clearance}") else: z_world_int = 1 if z_world else 0 await self.send_command(f"locZClearance {location_index} {z_clearance} {z_world_int}") - async def get_location_config(self, location_index: int) -> tuple[int, int]: - """Get the Config property for the specified location. - - Args: - location_index: The station index, from 1 to N_LOC. - - Returns: - A tuple containing (station_index, config_value) - config_value is a bit mask where: - - 0 = None (no configuration specified) - - 0x01 = GPL_Righty (right shouldered configuration) - - 0x02 = GPL_Lefty (left shouldered configuration) - - 0x04 = GPL_Above (elbow above the wrist) - - 0x08 = GPL_Below (elbow below the wrist) - - 0x10 = GPL_Flip (wrist pitched up) - - 0x20 = GPL_NoFlip (wrist pitched down) - - 0x1000 = GPL_Single (restrict wrist axis to +/- 180 degrees) - Values can be combined using bitwise OR. - """ + async def get_location_config(self, location_index): data = await self.send_command(f"locConfig {location_index}") parts = data.split(" ") - if len(parts) != 2: - raise PreciseFlexError(-1, "Unexpected response format from locConfig command.") + raise _new_module.PreciseFlexError(-1, "Unexpected response format from locConfig command.") + return (int(parts[0]), int(parts[1])) - station_index = int(parts[0]) - config_value = int(parts[1]) - - return (station_index, config_value) - - async def set_location_config(self, location_index: int, config_value: int) -> None: - """Set the Config property for the specified location. - - Args: - location_index: The station index, from 1 to N_LOC. - config_value: The new Config property value as a bit mask where: - - 0 = None (no configuration specified) - - 0x01 = GPL_Righty (right shouldered configuration) - - 0x02 = GPL_Lefty (left shouldered configuration) - - 0x04 = GPL_Above (elbow above the wrist) - - 0x08 = GPL_Below (elbow below the wrist) - - 0x10 = GPL_Flip (wrist pitched up) - - 0x20 = GPL_NoFlip (wrist pitched down) - - 0x1000 = GPL_Single (restrict wrist axis to +/- 180 degrees) - Values can be combined using bitwise OR. - - Raises: - ValueError: If config_value contains invalid bits or conflicting configurations. - """ - # Define valid bit masks - GPL_RIGHTY = 0x01 - GPL_LEFTY = 0x02 - GPL_ABOVE = 0x04 - GPL_BELOW = 0x08 - GPL_FLIP = 0x10 - GPL_NOFLIP = 0x20 - GPL_SINGLE = 0x1000 - - # All valid bits - ALL_VALID_BITS = ( - GPL_RIGHTY | GPL_LEFTY | GPL_ABOVE | GPL_BELOW | GPL_FLIP | GPL_NOFLIP | GPL_SINGLE - ) + async def set_location_config(self, location_index, config_value): + await self._new_backend._set_location_config(location_index, config_value) - # Check for invalid bits - if config_value & ~ALL_VALID_BITS: - raise ValueError(f"Invalid config bits specified: 0x{config_value:X}") + async def dest_c(self, arg1=0): + return await self._new_backend.dest_c(arg1) - # Check for conflicting configurations - if (config_value & GPL_RIGHTY) and (config_value & GPL_LEFTY): - raise ValueError("Cannot specify both GPL_Righty and GPL_Lefty") + async def dest_j(self, arg1=0): + return await self._new_backend.dest_j(arg1) - if (config_value & GPL_ABOVE) and (config_value & GPL_BELOW): - raise ValueError("Cannot specify both GPL_Above and GPL_Below") + async def here_j(self, location_index): + await self._new_backend.here_j(location_index) - if (config_value & GPL_FLIP) and (config_value & GPL_NOFLIP): - raise ValueError("Cannot specify both GPL_Flip and GPL_NoFlip") + async def here_c(self, location_index): + await self._new_backend.here_c(location_index) - await self.send_command(f"locConfig {location_index} {config_value}") + async def get_profile_speed(self, profile_index): + return await self._new_backend.get_profile_speed(profile_index) - async def dest_c(self, arg1: int = 0) -> tuple[float, float, float, float, float, float, int]: - """Get the destination or current Cartesian location of the robot. + async def set_profile_speed(self, profile_index, speed_percent): + await self._new_backend.set_profile_speed(profile_index, speed_percent) - Args: - arg1: Selects return value. Defaults to 0. - 0 = Return current Cartesian location if robot is not moving - 1 = Return target Cartesian location of the previous or current move + async def get_profile_speed2(self, profile_index): + return await self._new_backend.get_profile_speed2(profile_index) - Returns: - A tuple containing (X, Y, Z, yaw, pitch, roll, config) - If arg1 = 1 or robot is moving, returns the target location. - If arg1 = 0 and robot is not moving, returns the current location. - """ - if arg1 == 0: - data = await self.send_command("destC") - else: - data = await self.send_command(f"destC {arg1}") + async def set_profile_speed2(self, profile_index, speed2_percent): + await self._new_backend.set_profile_speed2(profile_index, speed2_percent) - parts = data.split() - if len(parts) != 7: - raise PreciseFlexError(-1, "Unexpected response format from destC command.") + async def get_profile_accel(self, profile_index): + return await self._new_backend.get_profile_accel(profile_index) - x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts[:6]) - config = int(parts[6]) + async def set_profile_accel(self, profile_index, accel_percent): + await self._new_backend.set_profile_accel(profile_index, accel_percent) - return (x, y, z, yaw, pitch, roll, config) + async def get_profile_accel_ramp(self, profile_index): + return await self._new_backend.get_profile_accel_ramp(profile_index) - async def dest_j(self, arg1: int = 0) -> Dict[int, float]: - """Get the destination or current joint location of the robot. + async def set_profile_accel_ramp(self, profile_index, accel_ramp_seconds): + await self._new_backend.set_profile_accel_ramp(profile_index, accel_ramp_seconds) - Args: - arg1: Selects return value. Defaults to 0. - 0 = Return current joint location if robot is not moving - 1 = Return target joint location of the previous or current move + async def get_profile_decel(self, profile_index): + return await self._new_backend.get_profile_decel(profile_index) - Returns: - A dict mapping PFAxis to float values. - If arg1 = 1 or robot is moving, returns the target joint positions. - If arg1 = 0 and robot is not moving, returns the current joint positions. - """ - if arg1 == 0: - data = await self.send_command("destJ") - else: - data = await self.send_command(f"destJ {arg1}") + async def set_profile_decel(self, profile_index, decel_percent): + await self._new_backend.set_profile_decel(profile_index, decel_percent) - parts = data.split() - if not parts: - raise PreciseFlexError(-1, "Unexpected response format from destJ command.") + async def get_profile_decel_ramp(self, profile_index): + return await self._new_backend.get_profile_decel_ramp(profile_index) - return self._parse_angles_response(parts) + async def set_profile_decel_ramp(self, profile_index, decel_ramp_seconds): + await self._new_backend.set_profile_decel_ramp(profile_index, decel_ramp_seconds) - async def here_j(self, location_index: int) -> None: - """Record the current position of the selected robot into the specified Location as angles. - - The Location is automatically set to type "angles". + async def get_profile_in_range(self, profile_index): + return await self._new_backend.get_profile_in_range(profile_index) - Args: - location_index: The station index, from 1 to N_LOC. - """ - await self.send_command(f"hereJ {location_index}") + async def set_profile_in_range(self, profile_index, in_range_value): + await self._new_backend.set_profile_in_range(profile_index, in_range_value) - async def here_c(self, location_index: int) -> None: - """Record the current position of the selected robot into the specified Location as Cartesian. - - The Location object is automatically set to type "Cartesian". - Can be used to change the pallet origin (index 1,1,1) value. - - Args: - location_index: The station index, from 1 to N_LOC. - """ - await self.send_command(f"hereC {location_index}") + async def get_profile_straight(self, profile_index): + return await self._new_backend.get_profile_straight(profile_index) - # region PROFILE COMMANDS - - async def get_profile_speed(self, profile_index: int) -> float: - """Get the speed property of the specified profile. - - Args: - profile_index: The profile index to query. - - Returns: - float: The current speed as a percentage. 100 = full speed. - """ - response = await self.send_command(f"Speed {profile_index}") - profile, speed = response.split() - return float(speed) - - async def set_profile_speed(self, profile_index: int, speed_percent: float) -> None: - """Set the speed property of the specified profile. - - Args: - profile_index: The profile index to modify. - speed_percent: The new speed as a percentage. 100 = full speed. - Values > 100 may be accepted depending on system configuration. - """ - await self.send_command(f"Speed {profile_index} {speed_percent}") - - async def get_profile_speed2(self, profile_index: int) -> float: - """Get the speed2 property of the specified profile. - - Args: - profile_index: The profile index to query. - - Returns: - float: The current speed2 as a percentage. Used for Cartesian moves. - """ - response = await self.send_command(f"Speed2 {profile_index}") - profile, speed2 = response.split() - return float(speed2) - - async def set_profile_speed2(self, profile_index: int, speed2_percent: float) -> None: - """Set the speed2 property of the specified profile. - - Args: - profile_index: The profile index to modify. - speed2_percent: The new speed2 as a percentage. 100 = full speed. - Used for Cartesian moves. Normally set to 0. - """ - await self.send_command(f"Speed2 {profile_index} {speed2_percent}") - - async def get_profile_accel(self, profile_index: int) -> float: - """Get the acceleration property of the specified profile. - - Args: - profile_index: The profile index to query. - - Returns: - float: The current acceleration as a percentage. 100 = maximum acceleration. - """ - response = await self.send_command(f"Accel {profile_index}") - profile, accel = response.split() - return float(accel) - - async def set_profile_accel(self, profile_index: int, accel_percent: float) -> None: - """Set the acceleration property of the specified profile. - - Args: - profile_index: The profile index to modify. - accel_percent: The new acceleration as a percentage. 100 = maximum acceleration. - Maximum value depends on system configuration. - """ - await self.send_command(f"Accel {profile_index} {accel_percent}") - - async def get_profile_accel_ramp(self, profile_index: int) -> float: - """Get the acceleration ramp property of the specified profile. - - Args: - profile_index: The profile index to query. - - Returns: - float: The current acceleration ramp time in seconds. - """ - response = await self.send_command(f"AccRamp {profile_index}") - profile, accel_ramp = response.split() - return float(accel_ramp) - - async def set_profile_accel_ramp(self, profile_index: int, accel_ramp_seconds: float) -> None: - """Set the acceleration ramp property of the specified profile. - - Args: - profile_index: The profile index to modify. - accel_ramp_seconds: The new acceleration ramp time in seconds. - """ - await self.send_command(f"AccRamp {profile_index} {accel_ramp_seconds}") - - async def get_profile_decel(self, profile_index: int) -> float: - """Get the deceleration property of the specified profile. - - Args: - profile_index: The profile index to query. - - Returns: - float: The current deceleration as a percentage. 100 = maximum deceleration. - """ - response = await self.send_command(f"Decel {profile_index}") - profile, decel = response.split() - return float(decel) - - async def set_profile_decel(self, profile_index: int, decel_percent: float) -> None: - """Set the deceleration property of the specified profile. - - Args: - profile_index: The profile index to modify. - decel_percent: The new deceleration as a percentage. 100 = maximum deceleration. - Maximum value depends on system configuration. - """ - await self.send_command(f"Decel {profile_index} {decel_percent}") - - async def get_profile_decel_ramp(self, profile_index: int) -> float: - """Get the deceleration ramp property of the specified profile. - - Args: - profile_index: The profile index to query. - - Returns: - float: The current deceleration ramp time in seconds. - """ - response = await self.send_command(f"DecRamp {profile_index}") - profile, decel_ramp = response.split() - return float(decel_ramp) - - async def set_profile_decel_ramp(self, profile_index: int, decel_ramp_seconds: float) -> None: - """Set the deceleration ramp property of the specified profile. - - Args: - profile_index: The profile index to modify. - decel_ramp_seconds: The new deceleration ramp time in seconds. - """ - await self.send_command(f"DecRamp {profile_index} {decel_ramp_seconds}") - - async def get_profile_in_range(self, profile_index: int) -> float: - """Get the InRange property of the specified profile. - - Args: - profile_index: The profile index to query. - - Returns: - float: The current InRange value (-1 to 100). - -1 = do not stop at end of motion if blending is possible - 0 = always stop but do not check end point error - > 0 = wait until close to end point (larger numbers mean less position error allowed) - """ - response = await self.send_command(f"InRange {profile_index}") - profile, in_range = response.split() - return float(in_range) - - async def set_profile_in_range(self, profile_index: int, in_range_value: float) -> None: - """Set the InRange property of the specified profile. - - Args: - profile_index: The profile index to modify. - in_range_value: The new InRange value from -1 to 100. - -1 = do not stop at end of motion if blending is possible - 0 = always stop but do not check end point error - > 0 = wait until close to end point (larger numbers mean less position error allowed) - - Raises: - ValueError: If in_range_value is not between -1 and 100. - """ - if not (-1 <= in_range_value <= 100): - raise ValueError("InRange value must be between -1 and 100") - await self.send_command(f"InRange {profile_index} {in_range_value}") - - async def get_profile_straight(self, profile_index: int) -> bool: - """Get the Straight property of the specified profile. - - Args: - profile_index: The profile index to query. - - Returns: - The current Straight property value. - True = follow a straight-line path - False = follow a joint-based path (coordinated axes movement) - """ - response = await self.send_command(f"Straight {profile_index}") - profile, straight = response.split() - - return straight == "True" - - async def set_profile_straight(self, profile_index: int, straight_mode: bool) -> None: - """Set the Straight property of the specified profile. - - Args: - profile_index: The profile index to modify. - straight_mode: The path type to use. - True = follow a straight-line path - False = follow a joint-based path (robot axes move in coordinated manner) - - Raises: - ValueError: If straight_mode is not True or False. - """ - straight_int = 1 if straight_mode else 0 - await self.send_command(f"Straight {profile_index} {straight_int}") + async def set_profile_straight(self, profile_index, straight_mode): + await self._new_backend.set_profile_straight(profile_index, straight_mode) async def set_motion_profile_values( self, - profile: int, - speed: float, - speed2: float, - acceleration: float, - deceleration: float, - acceleration_ramp: float, - deceleration_ramp: float, - in_range: float, - straight: bool, + profile, + speed, + speed2, + acceleration, + deceleration, + acceleration_ramp, + deceleration_ramp, + in_range, + straight, ): - """ - Set motion profile values for the specified profile index on the PreciseFlex robot. - - Args: - profile: Profile index to set values for. - speed: Percentage of maximum speed. 100 = full speed. Values >100 may be accepted depending on system config. - speed2: Secondary speed setting, typically for Cartesian moves. Normally 0. Interpreted as a percentage. - acceleration: Percentage of maximum acceleration. 100 = full accel. - deceleration: Percentage of maximum deceleration. 100 = full decel. - acceleration_ramp: Acceleration ramp time in seconds. - deceleration_ramp: Deceleration ramp time in seconds. - in_range: InRange value, from -1 to 100. -1 = allow blending, 0 = stop without checking, >0 = enforce position accuracy. - straight: If True, follow a straight-line path (-1). If False, follow a joint-based path (0). - """ - if not (0 <= speed): - raise ValueError("Speed must be > 0 (percent).") - - if not (0 <= speed2): - raise ValueError("Speed2 must be > 0 (percent).") - - if not (0 <= acceleration <= 100): - raise ValueError("Acceleration must be between 0 and 100 (percent).") - - if not (0 <= deceleration <= 100): - raise ValueError("Deceleration must be between 0 and 100 (percent).") - - if acceleration_ramp < 0: - raise ValueError("Acceleration ramp must be >= 0 (seconds).") - - if deceleration_ramp < 0: - raise ValueError("Deceleration ramp must be >= 0 (seconds).") - - if not (-1 <= in_range <= 100): - raise ValueError("InRange must be between -1 and 100.") - - straight_int = -1 if straight else 0 - await self.send_command( - f"Profile {profile} {speed} {speed2} {acceleration} {deceleration} {acceleration_ramp} {deceleration_ramp} {in_range} {straight_int}" + await self._new_backend.set_motion_profile_values( + profile, + speed, + speed2, + acceleration, + deceleration, + acceleration_ramp, + deceleration_ramp, + in_range, + straight, ) - async def get_motion_profile_values( - self, profile: int - ) -> tuple[int, float, float, float, float, float, float, float, bool]: - """ - Get the current motion profile values for the specified profile index on the PreciseFlex robot. - - Args: - profile: Profile index to get values for. - - Returns: - A tuple containing (profile, speed, speed2, acceleration, deceleration, acceleration_ramp, deceleration_ramp, in_range, straight) - - profile: Profile index - - speed: Percentage of maximum speed - - speed2: Secondary speed setting - - acceleration: Percentage of maximum acceleration - - deceleration: Percentage of maximum deceleration - - acceleration_ramp: Acceleration ramp time in seconds - - deceleration_ramp: Deceleration ramp time in seconds - - in_range: InRange value (-1 to 100) - - straight: True if straight-line path, False if joint-based path - """ - data = await self.send_command(f"Profile {profile}") - parts = data.split(" ") - if len(parts) != 9: - raise PreciseFlexError(-1, "Unexpected response format from device.") - - return ( - int(parts[0]), - float(parts[1]), - float(parts[2]), - float(parts[3]), - float(parts[4]), - float(parts[5]), - float(parts[6]), - float(parts[7]), - False if int(parts[8]) == 0 else True, - ) - - # region MOTION COMMANDS - async def move_to_stored_location(self, location_index: int, profile_index: int) -> None: - """Move to the location specified by the station index using the specified profile. - - Args: - location_index: The index of the location to which the robot moves. - profile_index: The profile index for this move. - - Note: - Requires that the robot be attached. - """ - await self.send_command(f"move {location_index} {profile_index}") - - async def move_to_stored_location_appro(self, location_index: int, profile_index: int) -> None: - """Approach the location specified by the station index using the specified profile. - - This is similar to `move_to_stored_location` except that the Z clearance value is included. - - Args: - location_index: The index of the location to which the robot moves. - profile_index: The profile index for this move. + async def get_motion_profile_values(self, profile): + return await self._new_backend.get_motion_profile_values(profile) - Note: - Requires that the robot be attached. - """ - await self.send_command(f"moveAppro {location_index} {profile_index}") + async def move_to_stored_location(self, location_index, profile_index): + await self._new_backend._move_to_stored_location(location_index, profile_index) - async def move_extra_axis( - self, axis1_position: float, axis2_position: Optional[float] = None - ) -> None: - """Post a move for one or two extra axes during the next Cartesian motion. - - Does not cause the robot to move at this time. Only some kinematic modules support extra axes. - - Args: - axis1_position: The destination position for the 1st extra axis. - axis2_position (float, optional): The destination position for the 2nd extra axis, if any. + async def move_to_stored_location_appro(self, location_index, profile_index): + await self._new_backend._move_to_stored_location_appro(location_index, profile_index) - Note: - Requires that the robot be attached. - """ + async def move_extra_axis(self, axis1_position, axis2_position=None): if axis2_position is None: await self.send_command(f"moveExtraAxis {axis1_position}") else: await self.send_command(f"moveExtraAxis {axis1_position} {axis2_position}") - async def move_one_axis( - self, axis_number: int, destination_position: float, profile_index: int - ) -> None: - """Move a single axis to the specified position using the specified profile. - - Args: - axis_number: The number of the axis to move. - destination_position: The destination position for this axis. - profile_index: The index of the profile to use during this motion. - - Note: - Requires that the robot be attached. - """ + async def move_one_axis(self, axis_number, destination_position, profile_index): await self.send_command(f"moveOneAxis {axis_number} {destination_position} {profile_index}") - async def move_c( - self, - profile_index: int, - cartesian_coords: PreciseFlexCartesianCoords, - ) -> None: - """Move the robot to the Cartesian location specified by the arguments. - - Args: - profile_index: The profile index to use for this motion. - cartesian_coords: The Cartesian coordinates to which the robot should move. - - Note: - Requires that the robot be attached. - """ - - cmd = ( - f"moveC {profile_index} " - f"{cartesian_coords.location.x} " - f"{cartesian_coords.location.y} " - f"{cartesian_coords.location.z} " - f"{cartesian_coords.rotation.yaw} " - f"{cartesian_coords.rotation.pitch} " - f"{cartesian_coords.rotation.roll} " - ) - - if cartesian_coords.orientation is not None: - config_int = self._convert_orientation_enum_to_int(cartesian_coords.orientation) - config_int |= 0x1000 # GPL_Single: restrict wrist to ±180° - cmd += f"{config_int}" - - await self.send_command(cmd) - - async def move_j(self, profile_index: int, joint_coords: Dict[int, float]) -> None: - """Move the robot using joint coordinates, handling rail configuration.""" - if self._has_rail: - angles_str = ( - f"{joint_coords[PFAxis.BASE]} " - f"{joint_coords[PFAxis.SHOULDER]} " - f"{joint_coords[PFAxis.ELBOW]} " - f"{joint_coords[PFAxis.WRIST]} " - f"{joint_coords[PFAxis.GRIPPER]} " - f"{joint_coords[PFAxis.RAIL]} " - ) - else: - angles_str = ( - f"{joint_coords[PFAxis.BASE]} " - f"{joint_coords[PFAxis.SHOULDER]} " - f"{joint_coords[PFAxis.ELBOW]} " - f"{joint_coords[PFAxis.WRIST]} " - f"{joint_coords[PFAxis.GRIPPER]}" - ) - await self.send_command(f"moveJ {profile_index} {angles_str}") - - async def release_brake(self, axis: int) -> None: - """Release the axis brake. - - Overrides the normal operation of the brake. It is important that the brake not be set - while a motion is being performed. This feature is used to lock an axis to prevent - motion or jitter. - - Args: - axis: The number of the axis whose brake should be released. - """ - await self.send_command(f"releaseBrake {axis}") + async def move_c(self, profile_index, cartesian_coords): + converted = _to_new_coords(cartesian_coords) + if not isinstance(converted, _new_module.PreciseFlexGripperLocation): + raise TypeError("Expected cartesian coordinates, got joint position dict") + await self._new_backend._move_c(profile_index, converted) - async def set_brake(self, axis: int) -> None: - """Set the axis brake. + async def move_j(self, profile_index, joint_coords): + await self._new_backend._move_j(profile_index, joint_coords) - Overrides the normal operation of the brake. It is important not to set a brake on an - axis that is moving as it may damage the brake or damage the motor. + async def release_brake(self, axis): + await self._new_backend.release_brake(axis) - Args: - axis: The number of the axis whose brake should be set. - """ - await self.send_command(f"setBrake {axis}") - - async def state(self) -> str: - """Return state of motion. - - This value indicates the state of the currently executing or last completed robot motion. - For additional information, please see 'Robot.TrajState' in the GPL reference manual. - - Returns: - str: The current motion state. - """ - return await self.send_command("state") - - async def wait_for_eom(self) -> None: - """Wait for the robot to reach the end of the current motion. - - Waits for the robot to reach the end of the current motion or until it is stopped by - some other means. Does not reply until the robot has stopped. - """ - await self.send_command("waitForEom") - await asyncio.sleep(0.2) # Small delay to ensure command is fully processed - - async def zero_torque(self, enable: bool, axis_mask: int = 1) -> None: - """Sets or clears zero torque mode for the selected robot. - - Individual axes may be placed into zero torque mode while the remaining axes are servoing. - - Args: - enable: If True, enable torque mode for axes specified by axis_mask. If False, disable torque mode for the entire robot. - axis_mask: The bit mask specifying the axes to be placed in torque mode when enable is True. The mask is computed by OR'ing the axis bits: 1 = axis 1, 2 = axis 2, 4 = axis 3, 8 = axis 4, etc. Ignored when enable is False. - """ - - if enable: - assert axis_mask > 0, "axis_mask must be greater than 0" - await self.send_command(f"zeroTorque 1 {axis_mask}") - else: - await self.send_command("zeroTorque 0") - - # region PAROBOT COMMANDS - - async def change_config(self, grip_mode: int = 0) -> None: - """Change Robot configuration from Righty to Lefty or vice versa using customizable locations. - - Uses customizable locations to avoid hitting robot during change. - Does not include checks for collision inside work volume of the robot. - Can be customized by user for their work cell configuration. - - Args: - grip_mode: Gripper control mode. - 0 = do not change gripper (default) - 1 = open gripper - 2 = close gripper - """ - await self.send_command(f"ChangeConfig {grip_mode}") - - async def change_config2(self, grip_mode: int = 0) -> None: - """Change Robot configuration from Righty to Lefty or vice versa using algorithm. - - Uses an algorithm to avoid hitting robot during change. - Does not include checks for collision inside work volume of the robot. - Can be customized by user for their work cell configuration. - - Args: - grip_mode: Gripper control mode. - 0 = do not change gripper (default) - 1 = open gripper - 2 = close gripper - """ - await self.send_command(f"ChangeConfig2 {grip_mode}") - - async def get_grasp_data(self) -> tuple[float, float, float]: - """Get the data to be used for the next force-controlled PickPlate command grip operation. - - Returns: - A tuple containing (plate_width_mm, finger_speed_percent, grasp_force) - """ - data = await self.send_command("GraspData") - parts = data.split() + async def set_brake(self, axis): + await self._new_backend.set_brake(axis) - if len(parts) != 3: - raise PreciseFlexError(-1, "Unexpected response format from GraspData command.") + async def state(self): + return await self._new_driver.state() - plate_width = float(parts[0]) - finger_speed = float(parts[1]) - grasp_force = float(parts[2]) + async def wait_for_eom(self): + await self._new_driver._wait_for_eom() - return (plate_width, finger_speed, grasp_force) + async def zero_torque(self, enable, axis_mask=1): + await self._new_backend.zero_torque(enable, axis_mask) - async def set_grasp_data( - self, plate_width: float, finger_speed_percent: float, grasp_force: float - ) -> None: - """Set the data to be used for the next force-controlled PickPlate command grip operation. - - This data remains in effect until the next GraspData command or the system is restarted. - - Args: - plate_width_mm: The plate width in mm. - finger_speed_percent: The finger speed during grasp where 100 means 100%. - grasp_force: The gripper squeezing force, in Newtons. - A positive value indicates the fingers must close to grasp. - A negative value indicates the fingers must open to grasp. - """ - await self.send_command(f"GraspData {plate_width} {finger_speed_percent} {grasp_force}") - - async def _get_grip_close_pos(self) -> float: - """Get the gripper close position for the servoed gripper. - - Returns: - float: The current gripper close position. - """ - data = await self.send_command("GripClosePos") - return float(data) + async def change_config(self, grip_mode=0): + await self._new_backend.change_config(grip_mode) - async def _set_grip_close_pos(self, close_position: float) -> None: - """Set the gripper close position for the servoed gripper. + async def change_config2(self, grip_mode=0): + await self._new_backend.change_config2(grip_mode) - The close position may be changed by a force-controlled grip operation. + async def get_grasp_data(self): + return await self._new_backend._get_grasp_data() - Args: - close_position: The new gripper close position. - """ - await self.send_command(f"GripClosePos {close_position}") + async def set_grasp_data(self, plate_width, finger_speed_percent, grasp_force): + await self._new_backend._set_grasp_data(plate_width, finger_speed_percent, grasp_force) - async def _get_grip_open_pos(self) -> float: - """Get the gripper open position for the servoed gripper. + async def _get_grip_close_pos(self): + return await self._new_backend._get_grip_close_pos() - Returns: - float: The current gripper open position. - """ - data = await self.send_command("GripOpenPos") - return float(data) + async def _set_grip_close_pos(self, close_position): + await self._new_backend._set_grip_close_pos(close_position) - async def _set_grip_open_pos(self, open_position: float) -> None: - """Set the gripper open position for the servoed gripper. + async def _get_grip_open_pos(self): + return await self._new_backend._get_grip_open_pos() - Args: - open_position: The new gripper open position. - """ - await self.send_command(f"GripOpenPos {open_position}") + async def _set_grip_open_pos(self, open_position): + await self._new_backend._set_grip_open_pos(open_position) - async def move_rail( - self, station_id: Optional[int] = None, mode: int = 0, rail_destination: Optional[float] = None - ) -> None: - """Moves the optional linear rail. - - The rail may be moved immediately or simultaneously with the next pick or place motion. - The location may be associated with the station or specified explicitly. - - Args: - station_id: The destination station ID. Only used if rail_destination is omitted. - mode: Mode of operation. - 0 or omitted = cancel any pending MoveRail - 1 = Move rail immediately - 2 = Move rail during next pick or place - rail_destination (float, optional): If specified, use this value as the rail destination - rather than the station location. - """ + async def move_rail(self, station_id=None, mode=0, rail_destination=None): if rail_destination is not None: await self.send_command(f"MoveRail {station_id or ''} {mode} {rail_destination}") elif station_id is not None: @@ -1735,102 +547,39 @@ async def move_rail( else: await self.send_command(f"MoveRail {mode}") - async def get_pallet_index(self, station_id: int) -> tuple[int, int, int, int]: - """Get the current pallet index values for the specified station. - - Args: - station_id: Station ID, from 1 to N_LOC. - - Returns: - A tuple containing (station_id, pallet_index_x, pallet_index_y, pallet_index_z) - """ + async def get_pallet_index(self, station_id): data = await self.send_command(f"PalletIndex {station_id}") parts = data.split() - if len(parts) != 4: - raise PreciseFlexError(-1, "Unexpected response format from PalletIndex command.") - - station_id = int(parts[0]) - pallet_index_x = int(parts[1]) - pallet_index_y = int(parts[2]) - pallet_index_z = int(parts[3]) - - return (station_id, pallet_index_x, pallet_index_y, pallet_index_z) + raise _new_module.PreciseFlexError(-1, "Unexpected response format from PalletIndex command.") + return (int(parts[0]), int(parts[1]), int(parts[2]), int(parts[3])) async def set_pallet_index( - self, station_id: int, pallet_index_x: int = 0, pallet_index_y: int = 0, pallet_index_z: int = 0 - ) -> None: - """Set the pallet index value from 1 to n of the station used by subsequent pick or place. - - If an index argument is 0 or omitted, the corresponding index is not changed. - Negative values generate an error. - - Args: - station_id: Station ID, from 1 to N_LOC. - pallet_index_x: Pallet index X. If 0 or omitted, X index is not changed. - pallet_index_y: Pallet index Y. If 0 or omitted, Y index is not changed. - pallet_index_z: Pallet index Z. If 0 or omitted, Z index is not changed. - - Raises: - ValueError: If any index value is negative. - """ - if pallet_index_x < 0: - raise ValueError("Pallet index X cannot be negative") - if pallet_index_y < 0: - raise ValueError("Pallet index Y cannot be negative") - if pallet_index_z < 0: - raise ValueError("Pallet index Z cannot be negative") - + self, station_id, pallet_index_x=0, pallet_index_y=0, pallet_index_z=0 + ): await self.send_command( f"PalletIndex {station_id} {pallet_index_x} {pallet_index_y} {pallet_index_z}" ) - async def get_pallet_origin( - self, station_id: int - ) -> tuple[int, float, float, float, float, float, float, int]: - """Get the current pallet origin data for the specified station. - - Args: - station_id: Station ID, from 1 to N_LOC. - - Returns: - A tuple containing (station_id, x, y, z, yaw, pitch, roll, config) - """ + async def get_pallet_origin(self, station_id): data = await self.send_command(f"PalletOrigin {station_id}") parts = data.split() - if len(parts) != 8: - raise PreciseFlexError(-1, "Unexpected response format from PalletOrigin command.") - - station_id = int(parts[0]) - x = float(parts[1]) - y = float(parts[2]) - z = float(parts[3]) - yaw = float(parts[4]) - pitch = float(parts[5]) - roll = float(parts[6]) - config = int(parts[7]) - - return (station_id, x, y, z, yaw, pitch, roll, config) - - async def set_pallet_origin( - self, - station_id: int, - cartesian_coords: PreciseFlexCartesianCoords, - ) -> None: - """Define the origin of a pallet reference frame. - - Specifies the world location and orientation of the (1,1,1) pallet position. - Must be followed by a PalletX command. - - The orientation and configuration specified here determines the world orientation - of the robot during all pick or place operations using this pallet. - - Args: - station_id: Station ID, from 1 to N_LOC. - cartesian_coords: The Cartesian coordinates defining the pallet origin. - """ + raise _new_module.PreciseFlexError( + -1, "Unexpected response format from PalletOrigin command." + ) + return ( + int(parts[0]), + float(parts[1]), + float(parts[2]), + float(parts[3]), + float(parts[4]), + float(parts[5]), + float(parts[6]), + int(parts[7]), + ) + async def set_pallet_origin(self, station_id, cartesian_coords): cmd = ( f"PalletOrigin {station_id} " f"{cartesian_coords.location.x} " @@ -1840,162 +589,50 @@ async def set_pallet_origin( f"{cartesian_coords.rotation.pitch} " f"{cartesian_coords.rotation.roll} " ) - if cartesian_coords.orientation is not None: config_int = self._convert_orientation_enum_to_int(cartesian_coords.orientation) cmd += f"{config_int}" - await self.send_command(cmd) - async def get_pallet_x(self, station_id: int) -> tuple[int, int, float, float, float]: - """Get the current pallet X data for the specified station. - - Args: - station_id: Station ID, from 1 to N_LOC. - - Returns: - A tuple containing (station_id, x_position_count, world_x, world_y, world_z) - """ + async def get_pallet_x(self, station_id): data = await self.send_command(f"PalletX {station_id}") parts = data.split() - if len(parts) != 5: - raise PreciseFlexError(-1, "Unexpected response format from PalletX command.") - - station_id = int(parts[0]) - x_position_count = int(parts[1]) - world_x = float(parts[2]) - world_y = float(parts[3]) - world_z = float(parts[4]) - - return (station_id, x_position_count, world_x, world_y, world_z) + raise _new_module.PreciseFlexError(-1, "Unexpected response format from PalletX command.") + return (int(parts[0]), int(parts[1]), float(parts[2]), float(parts[3]), float(parts[4])) - async def set_pallet_x( - self, station_id: int, x_position_count: int, world_x: float, world_y: float, world_z: float - ) -> None: - """Define the last point on the pallet X axis. - - Specifies the world location of the (n,1,1) pallet position, where n is the x_position_count value. - Must follow a PalletOrigin command. - - Args: - station_id: Station ID, from 1 to N_LOC. - x_position_count: X position count. - world_x: World location X coordinate. - world_y: World location Y coordinate. - world_z: World location Z coordinate. - """ + async def set_pallet_x(self, station_id, x_position_count, world_x, world_y, world_z): await self.send_command( f"PalletX {station_id} {x_position_count} {world_x} {world_y} {world_z}" ) - async def get_pallet_y(self, station_id: int) -> tuple[int, int, float, float, float]: - """Get the current pallet Y data for the specified station. - - Args: - station_id: Station ID, from 1 to N_LOC. - - Returns: - A tuple containing (station_id, y_position_count, world_x, world_y, world_z) - """ + async def get_pallet_y(self, station_id): data = await self.send_command(f"PalletY {station_id}") parts = data.split() - if len(parts) != 5: - raise PreciseFlexError(-1, "Unexpected response format from PalletY command.") - - station_id = int(parts[0]) - y_position_count = int(parts[1]) - world_x = float(parts[2]) - world_y = float(parts[3]) - world_z = float(parts[4]) - - return (station_id, y_position_count, world_x, world_y, world_z) + raise _new_module.PreciseFlexError(-1, "Unexpected response format from PalletY command.") + return (int(parts[0]), int(parts[1]), float(parts[2]), float(parts[3]), float(parts[4])) - async def set_pallet_y( - self, station_id: int, y_position_count: int, world_x: float, world_y: float, world_z: float - ) -> None: - """Define the last point on the pallet Y axis. - - Specifies the world location of the (1,n,1) pallet position, where n is the y_position_count value. - If this command is executed, a 2 or 3-dimensional pallet is assumed. - Must follow a PalletX command. - - Args: - station_id: Station ID, from 1 to N_LOC. - y_position_count: Y position count. - world_x: World location X coordinate. - world_y: World location Y coordinate. - world_z: World location Z coordinate. - """ + async def set_pallet_y(self, station_id, y_position_count, world_x, world_y, world_z): await self.send_command( f"PalletY {station_id} {y_position_count} {world_x} {world_y} {world_z}" ) - async def get_pallet_z(self, station_id: int) -> tuple[int, int, float, float, float]: - """Get the current pallet Z data for the specified station. - - Args: - station_id: Station ID, from 1 to N_LOC. - - Returns: - A tuple containing (station_id, z_position_count, world_x, world_y, world_z) - """ + async def get_pallet_z(self, station_id): data = await self.send_command(f"PalletZ {station_id}") parts = data.split() - if len(parts) != 5: - raise PreciseFlexError(-1, "Unexpected response format from PalletZ command.") - - station_id = int(parts[0]) - z_position_count = int(parts[1]) - world_x = float(parts[2]) - world_y = float(parts[3]) - world_z = float(parts[4]) + raise _new_module.PreciseFlexError(-1, "Unexpected response format from PalletZ command.") + return (int(parts[0]), int(parts[1]), float(parts[2]), float(parts[3]), float(parts[4])) - return (station_id, z_position_count, world_x, world_y, world_z) - - async def set_pallet_z( - self, station_id: int, z_position_count: int, world_x: float, world_y: float, world_z: float - ) -> None: - """Define the last point on the pallet Z axis. - - Specifies the world location of the (1,1,n) pallet position, where n is the z_position_count value. - If this command is executed, a 3-dimensional pallet is assumed. - Must follow a PalletX and PalletY command. - - Args: - station_id: Station ID, from 1 to N_LOC. - z_position_count: Z position count. - world_x: World location X coordinate. - world_y: World location Y coordinate. - world_z: World location Z coordinate. - """ + async def set_pallet_z(self, station_id, z_position_count, world_x, world_y, world_z): await self.send_command( f"PalletZ {station_id} {z_position_count} {world_x} {world_y} {world_z}" ) async def pick_plate_station( - self, - station_id: int, - horizontal_compliance: bool = False, - horizontal_compliance_torque: int = 0, - ) -> bool: - """Moves to a predefined position or pallet location and picks up plate. - - If the arm must change configuration, it automatically goes through the Park position. - At the conclusion of this routine, the arm is left gripping the plate and stopped at the nest approach position. - Use Teach function to teach station pick point. - - Args: - station_id: Station ID, from 1 to Max. - horizontal_compliance: If True, enable horizontal compliance while closing the gripper to allow centering around the plate. - horizontal_compliance_torque: The % of the original horizontal holding torque to be retained during compliance. If omitted, 0 is used. - - Returns: - bool: True if the plate was successfully grasped or force control was not used. - False if the force-controlled gripper detected no plate present. - """ + self, station_id, horizontal_compliance=False, horizontal_compliance_torque=0 + ): horizontal_compliance_int = 1 if horizontal_compliance else 0 ret_code = await self.send_command( f"PickPlate {station_id} {horizontal_compliance_int} {horizontal_compliance_torque}" @@ -2003,359 +640,113 @@ async def pick_plate_station( return ret_code != "0" async def place_plate_station( - self, - station_id: int, - horizontal_compliance: bool = False, - horizontal_compliance_torque: int = 0, - ) -> None: - """Moves to a predefined position or pallet location and places a plate. - - If the arm must change configuration, it automatically goes through the Park position. - At the conclusion of this routine, the arm is left gripping the plate and stopped at the nest approach position. - Use Teach function to teach station place point. - - Args: - station_id: Station ID, from 1 to Max. - horizontal_compliance: If True, enable horizontal compliance during the move to place the plate, to allow centering in the fixture. - horizontal_compliance_torque: The % of the original horizontal holding torque to be retained during compliance. If omitted, 0 is used. - """ + self, station_id, horizontal_compliance=False, horizontal_compliance_torque=0 + ): horizontal_compliance_int = 1 if horizontal_compliance else 0 await self.send_command( f"PlacePlate {station_id} {horizontal_compliance_int} {horizontal_compliance_torque}" ) - async def get_rail_position(self, station_id: int) -> float: - """Get the position of the optional rail axis that is associated with a station. - - Args: - station_id: Station ID, from 1 to Max. - - Returns: - float: The current rail position for the specified station. - """ + async def get_rail_position(self, station_id): data = await self.send_command(f"Rail {station_id}") return float(data) - async def set_rail_position(self, station_id: int, rail_position: float) -> None: - """Set the position of the optional rail axis that is associated with a station. - - The station rail data is loaded and saved by the LoadFile and StoreFile commands. - - Args: - station_id: Station ID, from 1 to Max. - rail_position: The new rail position. - """ + async def set_rail_position(self, station_id, rail_position): await self.send_command(f"Rail {station_id} {rail_position}") - async def teach_plate_station(self, station_id: int, z_clearance: float = 50.0) -> None: - """Sets the plate location to the current robot position and configuration. - - The location is saved as Cartesian coordinates. Z clearance must be high enough to withdraw the gripper. - If this station is a pallet, the pallet indices must be set to 1, 1, 1. The pallet frame is not changed, - only the location relative to the pallet. - - Args: - station_id: Station ID, from 1 to Max. - z_clearance: The Z Clearance value. If omitted, a value of 50 is used. If specified and non-zero, this value is used. - """ + async def teach_plate_station(self, station_id, z_clearance=50.0): await self.send_command(f"TeachPlate {station_id} {z_clearance}") - async def get_station_type(self, station_id: int) -> tuple[int, int, int, float, float, float]: - """Get the station configuration for the specified station ID. - - Args: - station_id: Station ID, from 1 to Max. - - Returns: - A tuple containing (station_id, access_type, location_type, z_clearance, z_above, z_grasp_offset) - - station_id: The station ID - - access_type: 0 = horizontal, 1 = vertical - - location_type: 0 = normal single, 1 = pallet (1D, 2D, 3D) - - z_clearance: ZClearance value in mm - - z_above: ZAbove value in mm - - z_grasp_offset: ZGrasp offset - """ + async def get_station_type(self, station_id): data = await self.send_command(f"StationType {station_id}") parts = data.split() - if len(parts) != 6: - raise PreciseFlexError(-1, "Unexpected response format from StationType command.") - - station_id = int(parts[0]) - access_type = int(parts[1]) - location_type = int(parts[2]) - z_clearance = float(parts[3]) - z_above = float(parts[4]) - z_grasp_offset = float(parts[5]) - - return (station_id, access_type, location_type, z_clearance, z_above, z_grasp_offset) + raise _new_module.PreciseFlexError(-1, "Unexpected response format from StationType command.") + return ( + int(parts[0]), + int(parts[1]), + int(parts[2]), + float(parts[3]), + float(parts[4]), + float(parts[5]), + ) async def set_station_type( - self, - station_id: int, - access_type: int, - location_type: int, - z_clearance: float, - z_above: float, - z_grasp_offset: float, - ) -> None: - """Set the station configuration for the specified station ID. - - Args: - station_id: Station ID, from 1 to Max. - access_type: The station access type. - 0 = horizontal (for "hotel" carriers accessed by horizontal move) - 1 = vertical (for stacks or tube racks accessed with vertical motion) - location_type: The location type. - 0 = normal single location - 1 = pallet (1D, 2D, or 3D regular arrays requiring column, row, and layer index) - z_clearance: ZClearance value in mm. The horizontal or vertical distance - from the final location used when approaching or departing from a station. - z_above: ZAbove value in mm. The vertical offset used with horizontal - access when approaching or departing from the location. - z_grasp_offset: ZGrasp offset. Added to ZClearance when an object is - being held to compensate for the part in the gripper. - - Raises: - ValueError: If access_type or location_type are not valid values. - """ - if access_type not in [0, 1]: - raise ValueError("Access type must be 0 (horizontal) or 1 (vertical)") - - if location_type not in [0, 1]: - raise ValueError("Location type must be 0 (normal single) or 1 (pallet)") - + self, station_id, access_type, location_type, z_clearance, z_above, z_grasp_offset + ): await self.send_command( f"StationType {station_id} {access_type} {location_type} {z_clearance} {z_above} {z_grasp_offset}" ) - # region SSGRIP COMMANDS - - async def home_all_if_no_plate(self) -> int: - """Tests if the gripper is holding a plate. If not, enable robot power and home all robots. - - Returns: - -1 if no plate detected and the command succeeded, 0 if a plate was detected. - """ + async def home_all_if_no_plate(self): response = await self.send_command("HomeAll_IfNoPlate") return int(response) - async def _grasp_plate( - self, plate_width_mm: float, finger_speed_percent: int, grasp_force: float - ) -> int: - """Grasps a plate with limited force. - - Low level method. Use `pick_plate` instead for typical pick-and-place operations. - - A plate can be grasped by opening or closing the gripper. The actual commanded gripper - width generated by this function is a few mm smaller (or larger) than plate_width_mm - to permit the servos PID loop to generate the gripping force. - - Args: - plate_width_mm: Plate width in mm. Should be accurate to within about 1 mm. - finger_speed_percent: Percent speed to close fingers. 1 to 100. - grasp_force: Maximum gripper squeeze force in Newtons. - A positive value indicates the fingers must close to grasp. - A negative value indicates the fingers must open to grasp. - - Returns: - -1 if the plate has been grasped, 0 if the final gripping force indicates no plate. - - Raises: - ValueError: If finger_speed_percent is not between 1 and 100. - """ - if not (1 <= finger_speed_percent <= 100): - raise ValueError("Finger speed percent must be between 1 and 100") - + async def _grasp_plate(self, plate_width_mm, finger_speed_percent, grasp_force): response = await self.send_command( f"GraspPlate {plate_width_mm} {finger_speed_percent} {grasp_force}" ) return int(response) - async def _release_plate( - self, open_width_mm: float, finger_speed_percent: int, in_range: float = 0.0 - ) -> None: - """Releases the plate after a GraspPlate command. - - Low level method. Use `place_plate` instead for typical pick-and-place operations. - - Opens (or closes) the gripper to the specified width and cancels the force limit - once the plate is released to avoid applying an excessive force to the plate. - - Args: - open_width_mm: Open width in mm. - finger_speed_percent: Percent speed to open fingers. 1 to 100. - in_range: Optional. The standard InRange profile property for the gripper open move. - If omitted, a zero value is assumed. - - Raises: - ValueError: If finger_speed_percent is not between 1 and 100. - """ - if not (1 <= finger_speed_percent <= 100): - raise ValueError("Finger speed percent must be between 1 and 100") - + async def _release_plate(self, open_width_mm, finger_speed_percent, in_range=0.0): await self.send_command(f"ReleasePlate {open_width_mm} {finger_speed_percent} {in_range}") - async def is_gripper_closed(self) -> bool: - """(Single Gripper Only) Tests if the gripper is fully closed by checking the end-of-travel sensor. - - Returns: - For standard gripper: True if the gripper is within 2mm of fully closed, otherwise False. - """ - if self._is_dual_gripper: - raise ValueError("IsGripperClosed command is only valid for single gripper robots.") - response = await self.send_command("IsFullyClosed") - return int(response) == -1 - - async def are_grippers_closed(self) -> tuple[bool, bool]: - """(Dual Gripper Only) Tests if each gripper is fully closed by checking the end-of-travel sensors.""" - if not self._is_dual_gripper: - raise ValueError("AreGrippersClosed command is only valid for dual gripper robots.") - response = await self.send_command("IsFullyClosed") - ret_int = int(response) - gripper_1_closed = (ret_int & 1) != 0 - gripper_2_closed = (ret_int & 2) != 0 - return (gripper_1_closed, gripper_2_closed) - - async def set_active_gripper( - self, gripper_id: int, spin_mode: int = 0, profile_index: Optional[int] = None - ) -> None: - """(Dual Gripper Only) Sets the currently active gripper and modifies the tool reference frame. - - Args: - gripper_id: Gripper ID, either 1 or 2. Determines which gripper is set to active. - spin_mode: Optional spin mode. - 0 or omitted = do not rotate the gripper 180deg immediately. - 1 = Rotate gripper 180deg immediately. - profile_index: Profile Index to use for spin motion. - - Raises: - ValueError: If gripper_id is not 1 or 2, or if spin_mode is not 0 or 1. - """ - if gripper_id not in [1, 2]: - raise ValueError("Gripper ID must be 1 or 2") - - if spin_mode not in [0, 1]: - raise ValueError("Spin mode must be 0 or 1") - + async def set_active_gripper(self, gripper_id, spin_mode=0, profile_index=None): if profile_index is not None: await self.send_command(f"SetActiveGripper {gripper_id} {spin_mode} {profile_index}") else: await self.send_command(f"SetActiveGripper {gripper_id} {spin_mode}") - async def get_active_gripper(self) -> int: - """(Dual Gripper Only) Returns the currently active gripper. - - Returns: - 1 if Gripper A is active, 2 if Gripper B is active. - """ - if not self._is_dual_gripper: - raise ValueError("GetActiveGripper command is only valid for dual gripper robots.") + async def get_active_gripper(self): response = await self.send_command("GetActiveGripper") return int(response) - async def freedrive_mode(self, free_axes: List[int]) -> None: - """Enter freedrive mode, allowing manual movement of the specified joints. - - The robot must be attached to enter free mode. - - Args: - free_axes: List of joint indices to free. Use [0] for all axes. - """ - for axis in free_axes: - await self.send_command(f"freemode {axis}") - - async def end_freedrive_mode(self) -> None: - """Exit freedrive mode for all axes.""" - await self.send_command("freemode -1") - async def pick_plate_from_stored_position( - self, - position_id: int, - horizontal_compliance: bool = False, - horizontal_compliance_torque: int = 0, + self, position_id, horizontal_compliance=False, horizontal_compliance_torque=0 ): - """Pick an item at the specified position ID. - - Args: - position_id: The ID of the position where the plate should be picked. - horizontal_compliance: enable horizontal compliance while closing the gripper to allow centering around the plate. - horizontal_compliance_torque: The % of the original horizontal holding torque to be retained during compliance. If omitted, 0 is used. - """ horizontal_compliance_int = 1 if horizontal_compliance else 0 ret_code = await self.send_command( f"pickplate {position_id} {horizontal_compliance_int} {horizontal_compliance_torque}" ) if ret_code == "0": - raise PreciseFlexError(-1, "the force-controlled gripper detected no plate present.") + raise _new_module.PreciseFlexError( + -1, "the force-controlled gripper detected no plate present." + ) async def place_plate_to_stored_position( - self, - position_id: int, - horizontal_compliance: bool = False, - horizontal_compliance_torque: int = 0, + self, position_id, horizontal_compliance=False, horizontal_compliance_torque=0 ): - """Place an item at the specified position ID. - - Args: - position_id: The ID of the position where the plate should be placed. - horizontal_compliance: enable horizontal compliance during the move to place the plate, to allow centering in the fixture. - horizontal_compliance_torque: The % of the original horizontal holding torque to be retained during compliance. If omitted, 0 is used. - """ horizontal_compliance_int = 1 if horizontal_compliance else 0 await self.send_command( f"placeplate {position_id} {horizontal_compliance_int} {horizontal_compliance_torque}" ) - async def teach_position(self, position_id: int, z_clearance: float = 50.0): - """Sets the plate location to the current robot position and configuration. The location is saved as Cartesian coordinates. - - Args: - position_id: The ID of the position to be taught. - z_clearance: Optional. The Z Clearance value. If omitted, a value of 50 is used. Z clearance must be high enough to withdraw the gripper. - """ + async def teach_position(self, position_id, z_clearance=50.0): await self.send_command(f"teachplate {position_id} {z_clearance}") - def _parse_xyz_response( - self, parts: List[str] - ) -> tuple[float, float, float, float, float, float]: - if len(parts) != 6: - raise PreciseFlexError(-1, "Unexpected response format for Cartesian coordinates.") - - x = float(parts[0]) - y = float(parts[1]) - z = float(parts[2]) - yaw = float(parts[3]) - pitch = float(parts[4]) - roll = float(parts[5]) - - return (x, y, z, yaw, pitch, roll) - - def _parse_angles_response(self, parts: List[str]) -> Dict[int, float]: - """Parse angle values from a response string. - - For self._has_rail=True: wire order is [base, shoulder, elbow, wrist, gripper, rail] - For self._has_rail=False: wire order is [base, shoulder, elbow, wrist, gripper] - """ - - if len(parts) < 3: - raise PreciseFlexError(-1, "Unexpected response format for angles.") - - if self._has_rail: - return { - PFAxis.RAIL: float(parts[5]) if len(parts) > 5 else 0.0, - PFAxis.BASE: float(parts[0]), - PFAxis.SHOULDER: float(parts[1]), - PFAxis.ELBOW: float(parts[2]), - PFAxis.WRIST: float(parts[3]) if len(parts) > 3 else 0.0, - PFAxis.GRIPPER: float(parts[4]) if len(parts) > 4 else 0.0, - } - - return { - PFAxis.RAIL: 0.0, - PFAxis.BASE: float(parts[0]), - PFAxis.SHOULDER: float(parts[1]), - PFAxis.ELBOW: float(parts[2]) if len(parts) > 2 else 0.0, - PFAxis.WRIST: float(parts[3]) if len(parts) > 3 else 0.0, - PFAxis.GRIPPER: float(parts[4]) if len(parts) > 4 else 0.0, - } + def _parse_xyz_response(self, parts): + return self._new_backend._parse_xyz_response(parts) + + def _parse_angles_response(self, parts): + return self._new_backend._parse_angles_response(parts) + + async def _approach_j(self, *args, **kwargs): + return await self._new_backend._approach_j(*args, **kwargs) + + async def _pick_plate_j(self, *args, **kwargs): + return await self._new_backend._pick_plate_j(*args, **kwargs) + + async def _place_plate_j(self, *args, **kwargs): + return await self._new_backend._place_plate_j(*args, **kwargs) + + async def _approach_c(self, *args, **kwargs): + return await self._new_backend._approach_c(*args, **kwargs) + + async def _pick_plate_c(self, *args, **kwargs): + return await self._new_backend._pick_plate_c(*args, **kwargs) + + async def _place_plate_c(self, *args, **kwargs): + return await self._new_backend._place_plate_c(*args, **kwargs) + + async def _set_grip_detail(self, *args, **kwargs): + return await self._new_backend._set_grip_detail(*args, **kwargs) From e06134606f875b6964b5c4941caffcd177ca1104 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Fri, 27 Mar 2026 16:22:59 -0700 Subject: [PATCH 17/69] productive work --- pylabrobot/arms/backend.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pylabrobot/arms/backend.py b/pylabrobot/arms/backend.py index 35e744de690..678c021ff92 100644 --- a/pylabrobot/arms/backend.py +++ b/pylabrobot/arms/backend.py @@ -84,6 +84,9 @@ async def get_joint_position( """Get the current position of the arm in joint space.""" +Smokes = HasJoints + + class CanGrip(metaclass=ABCMeta): """Mixin for arms that have a gripper.""" From 1117f2cf57ecee7b19d382dfcba0ce1d76396d35 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 28 Mar 2026 12:40:43 -0700 Subject: [PATCH 18/69] Add STARAutoload class for Hamilton STAR autoload module control Extract autoload firmware protocol into a standalone class that takes a driver reference and operates on track numbers instead of Carrier objects. The legacy STARBackend and new STAR device can both wire into this class. Includes 36 tests covering all command types. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hamilton/liquid_handlers/star/autoload.py | 625 ++++++++++++++++++ .../liquid_handlers/star/autoload_tests.py | 278 ++++++++ 2 files changed, 903 insertions(+) create mode 100644 pylabrobot/hamilton/liquid_handlers/star/autoload.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/autoload_tests.py diff --git a/pylabrobot/hamilton/liquid_handlers/star/autoload.py b/pylabrobot/hamilton/liquid_handlers/star/autoload.py new file mode 100644 index 00000000000..0af65514609 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/autoload.py @@ -0,0 +1,625 @@ +"""STARAutoload: autoload module control for Hamilton STAR liquid handlers.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Tuple + +from pylabrobot.resources.barcode import Barcode, Barcode1DSymbology + +if TYPE_CHECKING: + from .driver import STARDriver + +logger = logging.getLogger(__name__) + + +class STARAutoload: + """Controls the autoload module on a Hamilton STAR. + + This is a plain helper class (not a CapabilityBackend). It encapsulates the + firmware protocol for the autoload subsystem and delegates I/O to the driver. + + Methods that the legacy backend called with ``Carrier`` objects now take + ``carrier_end_rail: int`` — the caller is responsible for computing the rail + from carrier geometry. + """ + + # 1D barcode symbology bitmask + # Each symbology corresponds to exactly one bit in the 8-bit barcode type field. + # Bit definitions from spec: + # Bit 0 = ISBT Standard + # Bit 1 = Code 128 (Subset B and C) + # Bit 2 = Code 39 + # Bit 3 = Codabar + # Bit 4 = Code 2of5 Interleaved + # Bit 5 = UPC A/E + # Bit 6 = YESN/EAN 8 + # Bit 7 = (unused / undocumented) + + barcode_1d_symbology_dict: Dict[Barcode1DSymbology, str] = { + "ISBT Standard": "01", # bit 0 + "Code 128 (Subset B and C)": "02", # bit 1 + "Code 39": "04", # bit 2 + "Codebar": "08", # bit 3 + "Code 2of5 Interleaved": "10", # bit 4 + "UPC A/E": "20", # bit 5 + "YESN/EAN 8": "40", # bit 6 + "ANY 1D": "7F", # bits 0-6 + } + + def __init__(self, driver: "STARDriver", instrument_size_slots: int = 54): + self._driver = driver + self._instrument_size_slots = instrument_size_slots + self._default_1d_symbology: Barcode1DSymbology = "Code 128 (Subset B and C)" + + # -- initialization -------------------------------------------------------- + + async def initialize(self): + """Initialize Auto load module (C0:II).""" + return await self._driver.send_command(module="C0", command="II") + + async def request_initialization_status(self) -> bool: + """Request autoload initialization status (I0:QW).""" + resp = await self._driver.send_command(module="I0", command="QW", fmt="qw#") + return resp is not None and resp["qw"] == 1 + + # -- z-position safety ----------------------------------------------------- + + async def move_to_safe_z_position(self): + """Move autoload carrier handling wheel to safe Z position (C0:IV).""" + return await self._driver.send_command(module="C0", command="IV") + + # -- position queries ------------------------------------------------------ + + async def request_track(self) -> int: + """Request current track of the autoload carrier handler (C0:QA). + + Returns: + track (0..54) + """ + resp = await self._driver.send_command(module="C0", command="QA", fmt="qa##") + return int(resp["qa"]) + + async def request_type(self) -> str: + """Query the autoload module type (C0:CQ). + + Returns: + Human-readable autoload module type string, or the raw code if unknown. + """ + + autoload_type_dict = { + 0: "ML-STAR with 1D Barcode Scanner", + 1: "XRP Lite", + 2: "ML-STAR with 2D Barcode Scanner", + } + + resp = await self._driver.send_command(module="C0", command="CQ", fmt="cq#") + resp = autoload_type_dict[resp["cq"]] if resp["cq"] in autoload_type_dict else resp["cq"] + + return str(resp) + + # -- carrier sensing ------------------------------------------------------- + + @staticmethod + def _decode_hex_bitmask_to_track_list(mask_hex: str) -> List[int]: + """Decode a hex occupancy bitmask of arbitrary length. + + Each hex nibble = 4 slots. Slot numbering starts at 1 from the rightmost nibble (LSB). + """ + mask_hex = mask_hex.strip() + + if not all(c in "0123456789abcdefABCDEF" for c in mask_hex): + raise ValueError(f"Invalid hex in mask: {mask_hex!r}") + + slots: List[int] = [] + bit_index = 1 + + for nibble in reversed(mask_hex): + val = int(nibble, 16) + for bit in range(4): + if val & (1 << bit): + slots.append(bit_index) + bit_index += 1 + + return sorted(slots) + + async def request_presence_of_carriers_on_deck(self) -> List[int]: + """Read the deck carrier presence sensors (C0:RC). + + Returns: + Sorted list of deck rail positions where carriers are present. + """ + resp = await self._driver.send_command(module="C0", command="RC") + + ce_resp = resp.split("ce")[-1] + + return self._decode_hex_bitmask_to_track_list(ce_resp) + + async def request_presence_of_carriers_on_loading_tray(self) -> List[int]: + """Scan loading tray positions for carrier presence (C0:CS). + + Returns: + Sorted list of loading-tray positions where carriers are present. + """ + resp = await self._driver.send_command(module="C0", command="CS") + + if "cd" not in resp: + raise ValueError(f"CD field missing: {resp!r}") + + mask_hex = resp.split("cd", 1)[1].strip() + + return self._decode_hex_bitmask_to_track_list(mask_hex) + + async def request_presence_of_single_carrier_on_loading_tray(self, track: int) -> bool: + """Check whether a specific loading-tray track contains a carrier (C0:CT). + + Args: + track: The loading-tray track number to query (1-54). + + Returns: + True if a carrier is detected at the given track; False otherwise. + """ + + assert 1 <= track <= 54, "track must be between 1 and 54" + + track_str = str(track).zfill(2) + + resp = await self._driver.send_command( + module="C0", + command="CT", + fmt="ct#", + cp=track_str, + ) + assert resp is not None + + return int(resp["ct"]) == 1 + + # -- movement commands ----------------------------------------------------- + + async def move_to_track(self, track: int): + """Move autoload to specific track position (I0:XP).""" + + assert 1 <= track <= 54, "track must be between 1 and 54" + + await self.move_to_safe_z_position() + + track_no_as_safe_str = str(track).zfill(2) + return await self._driver.send_command(module="I0", command="XP", xp=track_no_as_safe_str) + + async def park(self): + """Park autoload to max position (I0:XP).""" + + max_x_pos = str(self._instrument_size_slots).zfill(2) + + await self.move_to_safe_z_position() + + return await self._driver.send_command(module="I0", command="XP", xp=max_x_pos) + + # -- belt operations ------------------------------------------------------- + + async def take_carrier_out_to_belt(self, carrier_end_rail: int): + """Take carrier out to identification position for barcode reading (C0:CN). + + Args: + carrier_end_rail: End rail position of the carrier on the deck. + """ + + carrier_on_loading_tray = await self.request_presence_of_single_carrier_on_loading_tray( + carrier_end_rail + ) + + if not carrier_on_loading_tray: + try: + await self._driver.send_command( + module="C0", + command="CN", + cp=str(carrier_end_rail).zfill(2), + ) + except Exception as e: + await self.move_to_safe_z_position() + raise RuntimeError( + f"Failed to take carrier at rail {carrier_end_rail} out to autoload belt: {e}" + ) + else: + raise ValueError(f"Carrier is already on the loading tray at position {carrier_end_rail}.") + + async def unload_carrier_after_barcode_scanning(self): + """Unload carrier back to loading tray after barcode scanning (C0:CA).""" + try: + resp = await self._driver.send_command( + module="C0", + command="CA", + ) + except Exception as e: + await self.move_to_safe_z_position() + raise RuntimeError(f"Failed to unload carrier after barcode scanning: {e}") + + return resp + + async def load_carrier_from_belt( + self, + barcode_reading: bool = False, + barcode_reading_direction: Literal["horizontal", "vertical"] = "horizontal", + barcode_symbology: Optional[Barcode1DSymbology] = None, + reading_position_of_first_barcode: float = 63.0, # mm + no_container_per_carrier: int = 5, + distance_between_containers: float = 96.0, # mm + width_of_reading_window: float = 38.0, # mm + reading_speed: float = 128.1, # mm/secs + park_autoload_after: bool = True, + ) -> Dict[int, Optional[Barcode]]: + """Finish loading a carrier currently on the autoload sled (C0:CL). + + Optionally reads container barcodes during the load. + """ + + assert barcode_reading_direction in ["horizontal", "vertical"] + assert 0 <= reading_position_of_first_barcode <= 470 + assert 0 <= no_container_per_carrier <= 32 + assert 0 <= distance_between_containers <= 470 + assert 0.1 <= width_of_reading_window <= 99.9 + assert 1.5 <= reading_speed <= 160.0 + + barcode_reading_direction_dict = { + "vertical": "0", + "horizontal": "1", + } + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + assert barcode_symbology is not None + + no_container_per_carrier_str = str(no_container_per_carrier).zfill(2) + reading_position_of_first_barcode_str = str( + round(reading_position_of_first_barcode * 10) + ).zfill(4) + distance_between_containers_str = str(round(distance_between_containers * 10)).zfill(4) + width_of_reading_window_str = str(round(width_of_reading_window * 10)).zfill(3) + reading_speed_str = str(round(reading_speed * 10)).zfill(4) + + if not barcode_reading: + barcode_reading_direction = "vertical" # no movement + no_container_per_carrier_str = "00" # no scanning + + else: + # Choose barcode symbology + await self.set_1d_barcode_type(barcode_symbology=barcode_symbology) + + self._default_1d_symbology = barcode_symbology + + try: + resp = await self._driver.send_command( + module="C0", + command="CL", + bd=barcode_reading_direction_dict[barcode_reading_direction], + bp=reading_position_of_first_barcode_str, + cn=no_container_per_carrier_str, + co=distance_between_containers_str, + cf=width_of_reading_window_str, + cv=reading_speed_str, + ) + except Exception as e: + await self.move_to_safe_z_position() + raise RuntimeError(f"Failed to load carrier from autoload belt: {e}") + + if park_autoload_after: + await self.park() + + assert isinstance(resp, str), f"Response is not a string: {resp!r}" + + barcode_dict: Dict[int, Optional[Barcode]] = {} + + if barcode_reading: + resp_list = resp.split("bb/")[-1].split("/") # remove header + + assert len(resp_list) == no_container_per_carrier, ( + f"Number of barcodes read ({len(resp_list)}) does not match " + f"expected number ({no_container_per_carrier})" + ) + for i in range(0, no_container_per_carrier): + if resp_list[i] == "00": + barcode_dict[i] = None + else: + barcode_dict[i] = Barcode( + data=resp_list[i], symbology=barcode_symbology, position_on_resource="right" + ) + + return barcode_dict + + # -- barcode commands ------------------------------------------------------ + + async def set_1d_barcode_type( + self, + barcode_symbology: Optional[Barcode1DSymbology], + ) -> None: + """Set 1D barcode type for autoload barcode reading (C0:CB).""" + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + + assert barcode_symbology is not None + + await self._driver.send_command( + module="C0", + command="CB", + bt=self.barcode_1d_symbology_dict[barcode_symbology], + ) + + self._default_1d_symbology = barcode_symbology + + async def load_carrier_from_tray_and_scan_carrier_barcode( + self, + carrier_end_rail: int, + carrier_barcode_reading: bool = True, + barcode_symbology: Optional[Barcode1DSymbology] = None, + barcode_position: float = 4.3, # mm + barcode_reading_window_width: float = 38.0, # mm + reading_speed: float = 128.1, # mm/sec + ) -> Optional[Barcode]: + """Load carrier from loading tray and optionally scan 1D carrier barcode (C0:CI). + + Args: + carrier_end_rail: End rail position of the carrier. + """ + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + + assert barcode_symbology is not None + + carrier_end_rail_str = str(carrier_end_rail).zfill(2) + + assert 1 <= int(carrier_end_rail_str) <= 54 + assert 0 <= barcode_position <= 470 + assert 0.1 <= barcode_reading_window_width <= 99.9 + assert 1.5 <= reading_speed <= 160.0 + + try: + resp = await self._driver.send_command( + module="C0", + command="CI", + cp=carrier_end_rail_str, + bi=f"{round(barcode_position * 10):04}", + bw=f"{round(barcode_reading_window_width * 10):03}", + co="0960", # Distance between containers (pattern) [0.1 mm] + cv=f"{round(reading_speed * 10):04}", + ) + except Exception as e: + if carrier_barcode_reading: + await self.move_to_safe_z_position() + raise RuntimeError( + f"Failed to load carrier at rail {carrier_end_rail} and scan barcode: {e}" + ) + else: + pass + + if not carrier_barcode_reading: + return None + + barcode_str = resp.split("bb/")[-1] + + return Barcode(data=barcode_str, symbology=barcode_symbology, position_on_resource="right") + + # -- high-level load / unload ---------------------------------------------- + + async def load_carrier( + self, + carrier_end_rail: int, + carrier_barcode_reading: bool = True, + barcode_reading: bool = False, + barcode_reading_direction: Literal["horizontal", "vertical"] = "horizontal", + barcode_symbology: Optional[Barcode1DSymbology] = None, + no_container_per_carrier: int = 5, + reading_position_of_first_barcode: float = 63.0, # mm + distance_between_containers: float = 96.0, # mm + width_of_reading_window: float = 38.0, # mm + reading_speed: float = 128.1, # mm/secs + park_autoload_after: bool = True, + ) -> dict: + """Use autoload to load carrier. + + Args: + carrier_end_rail: End rail position of the carrier (1-54). + carrier_barcode_reading: Whether to read the carrier barcode. Default True. + barcode_reading: Whether to read container barcodes. Default False. + barcode_reading_direction: Either "vertical" or "horizontal", default "horizontal". + barcode_symbology: Barcode symbology. Default "Code 128 (Subset B and C)". + no_container_per_carrier: Number of containers per carrier. Default 5. + park_autoload_after: Whether to park autoload after loading. Default True. + """ + + if barcode_symbology is None: + barcode_symbology = self._default_1d_symbology + + assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" + + # Determine presence of carrier at defined position + presence_check = await self.request_presence_of_single_carrier_on_loading_tray(carrier_end_rail) + + if presence_check != 1: + raise ValueError( + f"""No carrier found at position {carrier_end_rail}, + have you placed the carrier onto the correct autoload tray position?""" + ) + + # Scan carrier barcode + carrier_barcode = await self.load_carrier_from_tray_and_scan_carrier_barcode( + carrier_end_rail, carrier_barcode_reading=carrier_barcode_reading + ) + + # Load carrier + if barcode_reading: + await self.set_1d_barcode_type(barcode_symbology=barcode_symbology) + self._default_1d_symbology = barcode_symbology + + resp = await self.load_carrier_from_belt( + barcode_reading=barcode_reading, + barcode_reading_direction=barcode_reading_direction, + barcode_symbology=barcode_symbology, + reading_position_of_first_barcode=reading_position_of_first_barcode, + no_container_per_carrier=no_container_per_carrier, + distance_between_containers=distance_between_containers, + width_of_reading_window=width_of_reading_window, + reading_speed=reading_speed, + park_autoload_after=False, + ) + else: + resp = await self.load_carrier_from_belt( + barcode_reading=False, park_autoload_after=False + ) + + if park_autoload_after: + await self.park() + + output = { + "carrier_barcode": carrier_barcode if carrier_barcode_reading else None, + "container_barcodes": resp if barcode_reading else None, + } + + return output + + async def unload_carrier( + self, + carrier_end_rail: int, + park_autoload_after: bool = True, + ): + """Use autoload to unload carrier (C0:CR). + + Args: + carrier_end_rail: End rail position of the carrier (1-54). + """ + + assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" + + carrier_end_rail_str = str(carrier_end_rail).zfill(2) + + resp = await self._driver.send_command( + module="C0", + command="CR", + cp=carrier_end_rail_str, + ) + + if park_autoload_after: + await self.park() + + return resp + + # -- LED / monitoring ------------------------------------------------------ + + async def set_loading_indicators(self, bit_pattern: List[bool], blink_pattern: List[bool]): + """Set loading indicators (LEDs) (C0:CP). + + Args: + bit_pattern: On if True, off otherwise. Length 54. + blink_pattern: Blinking if True, steady otherwise. Length 54. + """ + + assert len(bit_pattern) == 54, "bit pattern must be length 54" + assert len(blink_pattern) == 54, "bit pattern must be length 54" + + def pattern2hex(pattern: List[bool]) -> str: + bit_string = "".join(["1" if x else "0" for x in pattern]) + return hex(int(bit_string, base=2))[2:].upper().zfill(14) + + bit_pattern_hex = pattern2hex(bit_pattern) + blink_pattern_hex = pattern2hex(blink_pattern) + + return await self._driver.send_command( + module="C0", + command="CP", + cl=bit_pattern_hex, + cb=blink_pattern_hex, + ) + + async def set_carrier_monitoring(self, should_monitor: bool = False): + """Set carrier monitoring (C0:CU). + + Args: + should_monitor: whether carrier should be monitored. + """ + + return await self._driver.send_command(module="C0", command="CU", cu=should_monitor) + + async def verify_and_wait_for_carriers( + self, + carrier_rails: List[Tuple[int, int]], + check_interval: float = 1.0, + ): + """Verify that carriers have been loaded at expected rail positions. + + Checks if carriers are physically present on the deck at the specified + rail positions using the deck's presence sensors. If any carriers are missing, it will: + 1. Prompt the user to load the missing carriers + 2. Flash LEDs at the missing positions using set_loading_indicators + 3. Continue checking until all carriers are detected + + Args: + carrier_rails: List of (start_rail, end_rail) tuples for expected carriers. + check_interval: Interval in seconds between presence checks (default: 1.0) + + Raises: + ValueError: If carrier_rails is empty. + """ + + if len(carrier_rails) == 0: + raise ValueError("No carriers found on deck. Assign carriers to the deck.") + + # The presence detection reports the end rail position + expected_end_rails = [end_rail for _, end_rail in carrier_rails] + + # Check initial presence + detected_rails = set(await self.request_presence_of_carriers_on_deck()) + missing_end_rails = sorted(set(expected_end_rails) - detected_rails) + + if len(missing_end_rails) == 0: + logger.info(f"All carriers detected at end rail positions: {expected_end_rails}") + await self.set_loading_indicators( + bit_pattern=[False] * 54, + blink_pattern=[False] * 54, + ) + print(f"\n✓ All carriers successfully detected at end rail positions: {expected_end_rails}\n") + return + + # Prompt user about missing carriers + print( + f"\n{'=' * 60}\n" + f"CARRIER LOADING REQUIRED\n" + f"{'=' * 60}\n" + f"Expected carriers at end rail positions: {expected_end_rails}\n" + f"Detected carriers at rail positions: {sorted(detected_rails)}\n" + f"Missing carriers at end rail positions: {missing_end_rails}\n" + f"{'=' * 60}\n" + f"Please load the missing carriers. LEDs will flash at the carrier positions.\n" + f"The system will automatically detect when all carriers are loaded.\n" + f"{'=' * 60}\n" + ) + + # Flash LEDs until all carriers are detected + while missing_end_rails: + bit_pattern = [False] * 54 + blink_pattern = [False] * 54 + + for missing_end_rail in missing_end_rails: + for start_rail, end_rail in carrier_rails: + if end_rail == missing_end_rail: + for rail in range(start_rail, end_rail + 1): + if 1 <= rail <= 54: + indicator_index = rail - 1 + bit_pattern[indicator_index] = True + blink_pattern[indicator_index] = True + break + + await self.set_loading_indicators(bit_pattern[::-1], blink_pattern[::-1]) + + await asyncio.sleep(check_interval) + + detected_rails = set(await self.request_presence_of_carriers_on_deck()) + missing_end_rails = sorted(set(expected_end_rails) - detected_rails) + + logger.info(f"All carriers successfully detected at end rail positions: {expected_end_rails}") + await self.set_loading_indicators( + bit_pattern=[False] * 54, + blink_pattern=[False] * 54, + ) + print("\n✓ All carriers successfully loaded and detected!\n") diff --git a/pylabrobot/hamilton/liquid_handlers/star/autoload_tests.py b/pylabrobot/hamilton/liquid_handlers/star/autoload_tests.py new file mode 100644 index 00000000000..910a0eb6177 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/autoload_tests.py @@ -0,0 +1,278 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.hamilton.liquid_handlers.star.autoload import STARAutoload + + +class TestAutoloadCommands(unittest.IsolatedAsyncioTestCase): + """Test that STARAutoload methods produce the correct firmware commands.""" + + async def asyncSetUp(self): + self.mock_driver = MagicMock() + self.mock_driver.send_command = AsyncMock() + self.autoload = STARAutoload(driver=self.mock_driver, instrument_size_slots=54) + + # -- initialization -------------------------------------------------------- + + async def test_initialize(self): + await self.autoload.initialize() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="II") + + async def test_request_initialization_status_true(self): + self.mock_driver.send_command.return_value = {"qw": 1} + result = await self.autoload.request_initialization_status() + self.assertTrue(result) + self.mock_driver.send_command.assert_called_once_with( + module="I0", command="QW", fmt="qw#" + ) + + async def test_request_initialization_status_false(self): + self.mock_driver.send_command.return_value = {"qw": 0} + result = await self.autoload.request_initialization_status() + self.assertFalse(result) + + # -- z-position safety ----------------------------------------------------- + + async def test_move_to_safe_z_position(self): + await self.autoload.move_to_safe_z_position() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="IV") + + # -- position queries ------------------------------------------------------ + + async def test_request_track(self): + self.mock_driver.send_command.return_value = {"qa": 12} + result = await self.autoload.request_track() + self.assertEqual(result, 12) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="QA", fmt="qa##" + ) + + async def test_request_type_1d(self): + self.mock_driver.send_command.return_value = {"cq": 0} + result = await self.autoload.request_type() + self.assertEqual(result, "ML-STAR with 1D Barcode Scanner") + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="CQ", fmt="cq#" + ) + + async def test_request_type_2d(self): + self.mock_driver.send_command.return_value = {"cq": 2} + result = await self.autoload.request_type() + self.assertEqual(result, "ML-STAR with 2D Barcode Scanner") + + async def test_request_type_unknown(self): + self.mock_driver.send_command.return_value = {"cq": 9} + result = await self.autoload.request_type() + self.assertEqual(result, "9") + + # -- carrier sensing ------------------------------------------------------- + + async def test_decode_hex_bitmask_empty(self): + self.assertEqual(STARAutoload._decode_hex_bitmask_to_track_list("0000"), []) + + async def test_decode_hex_bitmask_single(self): + # 0x01 = bit 0 set = slot 1 + self.assertEqual(STARAutoload._decode_hex_bitmask_to_track_list("01"), [1]) + + async def test_decode_hex_bitmask_multiple(self): + # 0x05 = bits 0 and 2 = slots 1 and 3 + self.assertEqual(STARAutoload._decode_hex_bitmask_to_track_list("05"), [1, 3]) + + async def test_decode_hex_bitmask_invalid(self): + with self.assertRaises(ValueError): + STARAutoload._decode_hex_bitmask_to_track_list("ZZ") + + async def test_request_presence_of_carriers_on_deck(self): + self.mock_driver.send_command.return_value = "C0RCid0001ce0005" + result = await self.autoload.request_presence_of_carriers_on_deck() + self.assertEqual(result, [1, 3]) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="RC") + + async def test_request_presence_of_carriers_on_loading_tray(self): + self.mock_driver.send_command.return_value = "C0CSid0001cd03" + result = await self.autoload.request_presence_of_carriers_on_loading_tray() + self.assertEqual(result, [1, 2]) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CS") + + async def test_request_presence_of_carriers_on_loading_tray_missing_cd(self): + self.mock_driver.send_command.return_value = "C0CSid0001xx00" + with self.assertRaises(ValueError): + await self.autoload.request_presence_of_carriers_on_loading_tray() + + async def test_request_presence_of_single_carrier_present(self): + self.mock_driver.send_command.return_value = {"ct": 1} + result = await self.autoload.request_presence_of_single_carrier_on_loading_tray(track=10) + self.assertTrue(result) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="CT", fmt="ct#", cp="10" + ) + + async def test_request_presence_of_single_carrier_absent(self): + self.mock_driver.send_command.return_value = {"ct": 0} + result = await self.autoload.request_presence_of_single_carrier_on_loading_tray(track=5) + self.assertFalse(result) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="CT", fmt="ct#", cp="05" + ) + + async def test_request_presence_of_single_carrier_invalid_track(self): + with self.assertRaises(AssertionError): + await self.autoload.request_presence_of_single_carrier_on_loading_tray(track=0) + with self.assertRaises(AssertionError): + await self.autoload.request_presence_of_single_carrier_on_loading_tray(track=55) + + # -- movement commands ----------------------------------------------------- + + async def test_move_to_track(self): + await self.autoload.move_to_track(track=12) + calls = self.mock_driver.send_command.call_args_list + # First call: move_to_safe_z_position (C0:IV) + self.assertEqual(calls[0].kwargs, {"module": "C0", "command": "IV"}) + # Second call: I0:XP + self.assertEqual(calls[1].kwargs, {"module": "I0", "command": "XP", "xp": "12"}) + + async def test_move_to_track_invalid(self): + with self.assertRaises(AssertionError): + await self.autoload.move_to_track(track=0) + with self.assertRaises(AssertionError): + await self.autoload.move_to_track(track=55) + + async def test_park(self): + await self.autoload.park() + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[0].kwargs, {"module": "C0", "command": "IV"}) + self.assertEqual(calls[1].kwargs, {"module": "I0", "command": "XP", "xp": "54"}) + + async def test_park_custom_slots(self): + self.autoload._instrument_size_slots = 30 + await self.autoload.park() + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[1].kwargs, {"module": "I0", "command": "XP", "xp": "30"}) + + # -- belt operations ------------------------------------------------------- + + async def test_take_carrier_out_to_belt(self): + # Carrier not on tray -> should proceed with CN command + self.mock_driver.send_command.side_effect = [ + {"ct": 0}, # presence check returns absent + None, # CN command + ] + await self.autoload.take_carrier_out_to_belt(carrier_end_rail=10) + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[0].kwargs, {"module": "C0", "command": "CT", "fmt": "ct#", "cp": "10"}) + self.assertEqual(calls[1].kwargs, {"module": "C0", "command": "CN", "cp": "10"}) + + async def test_take_carrier_out_to_belt_already_on_tray(self): + self.mock_driver.send_command.return_value = {"ct": 1} + with self.assertRaises(ValueError, msg="already on the loading tray"): + await self.autoload.take_carrier_out_to_belt(carrier_end_rail=10) + + async def test_unload_carrier_after_barcode_scanning(self): + await self.autoload.unload_carrier_after_barcode_scanning() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CA") + + # -- barcode commands ------------------------------------------------------ + + async def test_set_1d_barcode_type(self): + await self.autoload.set_1d_barcode_type("Code 39") + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="CB", bt="04" + ) + self.assertEqual(self.autoload._default_1d_symbology, "Code 39") + + async def test_set_1d_barcode_type_default(self): + await self.autoload.set_1d_barcode_type(None) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="CB", bt="02" # Code 128 default + ) + + async def test_load_carrier_from_tray_and_scan_carrier_barcode(self): + self.mock_driver.send_command.return_value = "C0CIid0001bb/ABC123" + result = await self.autoload.load_carrier_from_tray_and_scan_carrier_barcode( + carrier_end_rail=10, + carrier_barcode_reading=True, + ) + self.assertIsNotNone(result) + self.assertEqual(result.data, "ABC123") + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="CI", + cp="10", + bi="0043", + bw="380", + co="0960", + cv="1281", + ) + + async def test_load_carrier_from_tray_no_barcode_reading(self): + self.mock_driver.send_command.return_value = "C0CIid0001" + result = await self.autoload.load_carrier_from_tray_and_scan_carrier_barcode( + carrier_end_rail=10, + carrier_barcode_reading=False, + ) + self.assertIsNone(result) + + # -- high-level load / unload ---------------------------------------------- + + async def test_unload_carrier(self): + self.mock_driver.send_command.side_effect = [ + "C0CRid0001", # CR command + None, # safe z + None, # park XP + ] + await self.autoload.unload_carrier(carrier_end_rail=10, park_autoload_after=True) + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[0].kwargs, {"module": "C0", "command": "CR", "cp": "10"}) + + async def test_unload_carrier_no_park(self): + self.mock_driver.send_command.return_value = "C0CRid0001" + await self.autoload.unload_carrier(carrier_end_rail=10, park_autoload_after=False) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="CR", cp="10" + ) + + async def test_unload_carrier_invalid_rail(self): + with self.assertRaises(AssertionError): + await self.autoload.unload_carrier(carrier_end_rail=0) + with self.assertRaises(AssertionError): + await self.autoload.unload_carrier(carrier_end_rail=55) + + # -- LED / monitoring ------------------------------------------------------ + + async def test_set_loading_indicators(self): + bit_pattern = [True] + [False] * 53 + blink_pattern = [False] * 53 + [True] + await self.autoload.set_loading_indicators(bit_pattern, blink_pattern) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="CP", + cl="20000000000000", + cb="00000000000001", + ) + + async def test_set_loading_indicators_invalid_length(self): + with self.assertRaises(AssertionError): + await self.autoload.set_loading_indicators([True] * 10, [False] * 10) + + async def test_set_carrier_monitoring(self): + await self.autoload.set_carrier_monitoring(should_monitor=True) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="CU", cu=True + ) + + async def test_load_carrier_from_belt_no_barcode(self): + self.mock_driver.send_command.side_effect = [ + "C0CLid0001", # CL command + None, # safe z (park) + None, # park XP + ] + result = await self.autoload.load_carrier_from_belt( + barcode_reading=False, + park_autoload_after=True, + ) + self.assertEqual(result, {}) + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[0].kwargs["module"], "C0") + self.assertEqual(calls[0].kwargs["command"], "CL") + self.assertEqual(calls[0].kwargs["bd"], "0") # vertical when no barcode + self.assertEqual(calls[0].kwargs["cn"], "00") # no scanning From 619c7d597ed8d58ec375395307494b5a9d605a43 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 28 Mar 2026 12:48:00 -0700 Subject: [PATCH 19/69] Migrate legacy PlateReader to use capabilities PlateReader now delegates reads through AbsorbanceCapability, LuminescenceCapability, and FluorescenceCapability via adapter backends that wrap the legacy PlateReaderBackend. Extracted _DictBackendParams into pylabrobot/legacy/_backend_params.py for reuse across legacy adapters. Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/legacy/_backend_params.py | 11 + .../legacy/liquid_handling/liquid_handler.py | 696 +----------------- .../legacy/plate_reading/plate_reader.py | 179 ++++- 3 files changed, 181 insertions(+), 705 deletions(-) create mode 100644 pylabrobot/legacy/_backend_params.py diff --git a/pylabrobot/legacy/_backend_params.py b/pylabrobot/legacy/_backend_params.py new file mode 100644 index 00000000000..7113aa28e76 --- /dev/null +++ b/pylabrobot/legacy/_backend_params.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass, field +from typing import Any, Dict + +from pylabrobot.capabilities.capability import BackendParams + + +@dataclass +class _DictBackendParams(BackendParams): + """Wraps legacy **backend_kwargs into a BackendParams for the new capability interface.""" + + kwargs: Dict[str, Any] = field(default_factory=dict) diff --git a/pylabrobot/legacy/liquid_handling/liquid_handler.py b/pylabrobot/legacy/liquid_handling/liquid_handler.py index baf756d5eab..b6766633bf0 100644 --- a/pylabrobot/legacy/liquid_handling/liquid_handler.py +++ b/pylabrobot/legacy/liquid_handling/liquid_handler.py @@ -1,11 +1,8 @@ -"""Defines LiquidHandler class, the coordinator for liquid handling operations.""" - from __future__ import annotations import contextlib import inspect import json -import logging import unittest.mock import warnings from dataclasses import dataclass, field @@ -24,7 +21,6 @@ Union, ) -from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.liquid_handling.head96 import Head96Capability from pylabrobot.capabilities.liquid_handling.head96_backend import ( Head96Backend as _NewHead96Backend, @@ -117,7 +113,6 @@ SingleChannelDispense, ) -logger = logging.getLogger("pylabrobot") TipPresenceProbingMethod = Callable[ @@ -142,11 +137,7 @@ class BlowOutVolumeError(Exception): # --------------------------------------------------------------------------- -@dataclass -class _DictBackendParams(BackendParams): - """Wraps legacy **backend_kwargs into a BackendParams for the new capability interface.""" - - kwargs: Dict[str, Any] = field(default_factory=dict) +from pylabrobot.legacy._backend_params import _DictBackendParams # noqa: E402 class _LHAdapter(_NewLHBackend): @@ -321,13 +312,7 @@ async def dispense96( class LiquidHandler(Resource, Machine): - """ - Front end for liquid handlers. - - This class is the front end for liquid handlers; it provides a high-level interface for - interacting with liquid handlers. In the background, this class uses the low-level backend ( - defined in `pyhamilton.liquid_handling.backends`) to communicate with the liquid handler. - """ + """Deprecated. Use pylabrobot.hamilton.liquid_handlers.star.star.STAR instead.""" def __init__( self, @@ -336,15 +321,6 @@ def __init__( default_offset_head96: Optional[Coordinate] = None, name: Optional[str] = None, ): - """Initialize a LiquidHandler. - - Args: - backend: Backend to use. - deck: Deck to use. - default_offset_head96: Base offset applied to all 96-head operations. - name: Name of the liquid handler. If not provided, defaults to ``lh_{deck.name}``. - """ - Resource.__init__( self, name=name if name is not None else f"lh_{deck.name}", @@ -379,7 +355,6 @@ def __init__( @property def _resource_pickup(self) -> Optional[ResourcePickup]: - """Backward-compatible access to the first arm's pickup state.""" return self._resource_pickups.get(0) @_resource_pickup.setter @@ -387,8 +362,6 @@ def _resource_pickup(self, value: Optional[ResourcePickup]) -> None: self._resource_pickups[0] = value async def setup(self, **backend_kwargs): - """Prepare the robot for use.""" - if self.setup_finished: raise RuntimeError("The setup has already finished. See `LiquidHandler.stop`.") @@ -420,9 +393,6 @@ async def setup(self, **backend_kwargs): self._resource_pickups = {a: None for a in range(self.backend.num_arms)} def serialize_state(self) -> Dict[str, Any]: - """Serialize the state of this liquid handler. Use :meth:`~Resource.serialize_all_states` to - serialize the state of the liquid handler and all children (the deck).""" - head_state = {channel: tracker.serialize() for channel, tracker in self.head.items()} head96_state = ( {channel: tracker.serialize() for channel, tracker in self.head96.items()} @@ -440,9 +410,6 @@ def serialize_state(self) -> Dict[str, Any]: return {"head_state": head_state, "head96_state": head96_state, "arm_state": arm_state} def load_state(self, state: Dict[str, Any]): - """Load the liquid handler state from a file. Use :meth:`~Resource.load_all_state` to load the - state of the liquid handler and all children (the deck).""" - head_state = state["head_state"] for channel, tracker_state in head_state.items(): self.head[channel].load_state(tracker_state) @@ -456,16 +423,6 @@ def load_state(self, state: Dict[str, Any]): # _resource_pickup is set/cleared by pick_up_resource/drop_resource at runtime. def update_head_state(self, state: Dict[int, Optional[Tip]]): - """Update the state of the liquid handler head. - - All keys in `state` must be valid channels. Channels for which no key is specified will keep - their current state. - - Args: - state: A dictionary mapping channels to tips. If a channel is mapped to None, that channel - will have no tip. - """ - assert set(state.keys()).issubset(set(self.head.keys())), "Invalid channel." for channel, tip in state.items(): @@ -478,36 +435,17 @@ def update_head_state(self, state: Dict[int, Optional[Tip]]): self.head[channel].add_tip(tip) def clear_head_state(self): - """Clear the state of the liquid handler head.""" - self.update_head_state({c: None for c in self.head.keys()}) def summary(self): - """Prints a string summary of the deck layout.""" - print(self.deck.summary()) def _assert_positions_unique(self, positions: List[str]): - """Returns whether all items in `positions` are unique where they are not `None`. - - Args: - positions: List of positions. - """ - not_none = [p for p in positions if p is not None] if len(not_none) != len(set(not_none)): raise ValueError("Positions must be unique.") def _assert_resources_exist(self, resources: Sequence[Resource]): - """Checks that each resource in `resources` is assigned to the deck. - - Args: - resources: List of resources. - - Raises: - ValueError: If a resource is not assigned to the deck. - """ - for resource in resources: # names on the deck are unique, so we can simply check if the resource matches the one on # the deck (if any). @@ -523,22 +461,6 @@ def _check_args( default: Set[str], strictness: Strictness, ) -> Set[str]: - """Checks that the arguments to `method` are valid. - - Args: - method: Method to check. - backend_kwargs: Keyword arguments to `method`. - default: Default arguments to `method`. (Of the abstract backend) - strictness: Strictness level. If `Strictness.STRICT`, raises an error if there are extra - arguments. If `Strictness.WARN`, raises a warning. If `Strictness.IGNORE`, logs a debug - message. - - Raises: - TypeError: If the arguments are invalid. - - Returns: - The set of arguments that need to be removed from `backend_kwargs` before passing to `method`. - """ # if method is an AsyncMock, skip the checks if isinstance(method, unittest.mock.AsyncMock): @@ -580,18 +502,16 @@ def _check_args( elif strictness == Strictness.WARN: warnings.warn(f"Extra arguments to backend.{method.__name__}: {extra}") else: - logger.debug("Extra arguments to backend.%s: %s", method.__name__, extra) + pass return extra def _make_sure_channels_exist(self, channels: List[int]): - """Checks that the channels exist.""" invalid_channels = [c for c in channels if c not in self.head] if not len(invalid_channels) == 0: raise ValueError(f"Invalid channels: {invalid_channels}") def _format_param(self, value: Any) -> Any: - """Format parameters for logging.""" if isinstance(value, Resource): return value.name try: @@ -603,15 +523,8 @@ def _format_param(self, value: Any) -> Any: def _log_command(self, name: str, **kwargs) -> None: params = ", ".join(f"{k}={self._format_param(v)}" for k, v in kwargs.items()) - logger.debug("%s(%s)", name, params) def get_picked_up_resource(self) -> Optional[Resource]: - """Get the resource that is currently picked up. - - Returns: - The resource that is currently picked up, or `None` if no resource is being picked up. - """ - if self._resource_pickup is None: return None return self._resource_pickup.resource @@ -624,49 +537,6 @@ async def pick_up_tips( offsets: Optional[List[Coordinate]] = None, **backend_kwargs, ): - """Pick up tips from a resource. - - Examples: - Pick up all tips in the first column. - - >>> await lh.pick_up_tips(tips_resource["A1":"H1"]) - - Pick up tips on odd numbered rows, skipping the other channels. - - >>> await lh.pick_up_tips(tips_resource["A1", "C1", "E1", "G1"],use_channels=[0, 2, 4, 6]) - - Pick up tips from different tip resources: - - >>> await lh.pick_up_tips(tips_resource1["A1"] + tips_resource2["B2"] + tips_resource3["C3"]) - - Picking up tips with different offsets: - - >>> await lh.pick_up_tips( - ... tip_spots=tips_resource["A1":"C1"], - ... offsets=[ - ... Coordinate(0, 0, 0), # A1 - ... Coordinate(1, 1, 1), # B1 - ... Coordinate.zero() # C1 - ... ] - ... ) - - Args: - tip_spots: List of tip spots to pick up tips from. - use_channels: List of channels to use. Index from front to back. If `None`, the first - `len(channels)` channels will be used. - offsets: List of offsets, one for each channel: a translation that will be applied to the tip - drop location. - backend_kwargs: Additional keyword arguments for the backend, optional. - - Raises: - RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`. - - ValueError: If the positions are not unique. - - HasTipError: If a channel already has a tip. - - NoTipError: If a spot does not have a tip. - """ self._log_command( "pick_up_tips", @@ -696,11 +566,6 @@ async def pick_up_tips( ) def get_mounted_tips(self) -> List[Optional[Tip]]: - """Get the tips currently mounted on the head. - - Returns: - A list of tips currently mounted on the head, or `None` for channels without a tip. - """ return [tracker.get_tip() if tracker.has_tip else None for tracker in self.head.values()] @need_setup_finished @@ -712,47 +577,6 @@ async def drop_tips( allow_nonzero_volume: bool = False, **backend_kwargs, ): - """Drop tips to a resource. - - Examples: - Dropping tips to the first column. - - >>> await lh.pick_up_tips(tip_rack["A1:H1"]) - - Dropping tips with different offsets: - - >>> await lh.drop_tips( - ... channels=tips_resource["A1":"C1"], - ... offsets=[ - ... Coordinate(0, 0, 0), # A1 - ... Coordinate(1, 1, 1), # B1 - ... Coordinate.zero() # C1 - ... ] - ... ) - - Args: - tip_spots: Tip resource locations to drop to. - use_channels: List of channels to use. Index from front to back. If `None`, the first - `len(channels)` channels will be used. - offsets: List of offsets, one for each channel, a translation that will be applied to the tip - drop location. If `None`, no offset will be applied. - allow_nonzero_volume: If `True`, the tip will be dropped even if its volume is not zero (there - is liquid in the tip). If `False`, a RuntimeError will be raised if the tip has nonzero - volume. - backend_kwargs: Additional keyword arguments for the backend, optional. - - Raises: - RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`. - - ValueError: If no channel will pick up a tip, in other words, if all channels are `None` or - if the list of channels is empty. - - ValueError: If the positions are not unique. - - NoTipError: If a channel does not have a tip. - - HasTipError: If a spot already has a tip. - """ self._log_command( "drop_tips", @@ -790,23 +614,6 @@ async def return_tips( offsets: Optional[List[Coordinate]] = None, **backend_kwargs, ): - """Return all tips that are currently picked up to their original place. - - Examples: - Return the tips on the head to the tip rack where they were picked up: - - >>> await lh.pick_up_tips(tip_rack["A1"]) - >>> await lh.return_tips() - - Args: - use_channels: List of channels to use. Index from front to back. If `None`, all that have - tips will be used. - allow_nonzero_volume: If `True`, tips will be returned even if their volumes are not zero. - backend_kwargs: backend kwargs passed to `drop_tips`. - - Raises: - RuntimeError: If no tips have been picked up. - """ self._log_command( "return_tips", @@ -845,23 +652,6 @@ async def discard_tips( offsets: Optional[List[Coordinate]] = None, **backend_kwargs, ): - """Permanently discard tips in the trash. - - Examples: - Discarding the tips on channels 1 and 2: - - >>> await lh.discard_tips(use_channels=[0, 1]) - - Discarding all tips currently picked up: - - >>> await lh.discard_tips() - - Args: - use_channels: List of channels to use. Index from front to back. If `None`, all that have - tips will be used. - allow_nonzero_volume: If `True`, tips will be returned even if their volumes are not zero. - backend_kwargs: Additional keyword arguments for the backend, optional. - """ self._log_command( "discard_tips", @@ -904,16 +694,6 @@ async def move_tips( source_tip_spots: List[TipSpot], dest_tip_spots: List[TipSpot], ): - """Move tips from one tip rack to another. - - This is a convenience method that picks up tips from `source_tip_spots` and drops them to - `dest_tip_spots`. - - Examples: - Move tips from one tip rack to another: - - >>> await lh.move_tips(source_tip_rack["A1":"A8"], dest_tip_rack["B1":"B8"]) - """ if len(source_tip_spots) != len(dest_tip_spots): raise ValueError("Number of source and destination tip spots must match.") @@ -930,7 +710,6 @@ async def move_tips( ) def _check_containers(self, resources: Sequence[Resource]): - """Checks that all resources are containers.""" not_containers = [r for r in resources if not isinstance(r, Container)] if len(not_containers) > 0: raise TypeError(f"Resources must be `Container`s, got {not_containers}") @@ -949,59 +728,6 @@ async def aspirate( mix: Optional[List[Mix]] = None, **backend_kwargs, ): - """Aspirate liquid from the specified wells. - - Examples: - Aspirate a constant amount of liquid from the first column: - - >>> await lh.aspirate(plate["A1:H1"], 50) - - Aspirate an linearly increasing amount of liquid from the first column: - - >>> await lh.aspirate(plate["A1:H1"], range(0, 500, 50)) - - Aspirate arbitrary amounts of liquid from the first column: - - >>> await lh.aspirate(plate["A1:H1"], [0, 40, 10, 50, 100, 200, 300, 400]) - - Aspirate liquid from wells in different plates: - - >>> await lh.aspirate(plate["A1"] + plate2["A1"] + plate3["A1"], 50) - - Aspirating with a 10mm z-offset: - - >>> await lh.aspirate(plate["A1"], vols=50, offsets=[Coordinate(0, 0, 10)]) - - Aspirate from a blue bucket (big container), with the first 4 channels (which will be - spaced equally apart): - - >>> await lh.aspirate(blue_bucket, vols=50, use_channels=[0, 1, 2, 3]) - - Args: - resources: A list of wells to aspirate liquid from. Can be a single resource, or a list of - resources. If a single resource is specified, all channels will aspirate from the same - resource. - vols: A list of volumes to aspirate, one for each channel. If `vols` is a single number, then - all channels will aspirate that volume. - use_channels: List of channels to use. Index from front to back. If `None`, the first - `len(wells)` channels will be used. - flow_rates: the aspiration speed. In ul/s. If `None`, the backend default will be used. - offsets: List of offsets for each channel, a translation that will be applied to the - aspiration location. - liquid_height: The height of the liquid in the well wrt the bottom, in mm. - blow_out_air_volume: The volume of air to aspirate after the liquid, in ul. If `None`, the - backend default will be used. - spread: Used if aspirating from a single resource with multiple channels. If "tight", the - channels will be spaced as close as possible. If "wide", the channels will be spaced as far - apart as possible. If "custom", the user must specify the offsets wrt the center of the - resource. - backend_kwargs: Additional keyword arguments for the backend, optional. - - Raises: - RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`. - - ValueError: If all channels are `None`. - """ self._log_command( "aspirate", @@ -1054,57 +780,6 @@ async def dispense( mix: Optional[List[Mix]] = None, **backend_kwargs, ): - """Dispense liquid to the specified channels. - - Examples: - Dispense a constant amount of liquid to the first column: - - >>> await lh.dispense(plate["A1:H1"], 50) - - Dispense an linearly increasing amount of liquid to the first column: - - >>> await lh.dispense(plate["A1:H1"], range(0, 500, 50)) - - Dispense arbitrary amounts of liquid to the first column: - - >>> await lh.dispense(plate["A1:H1"], [0, 40, 10, 50, 100, 200, 300, 400]) - - Dispense liquid to wells in different plates: - - >>> await lh.dispense((plate["A1"], 50), (plate2["A1"], 50), (plate3["A1"], 50)) - - Dispensing with a 10mm z-offset: - - >>> await lh.dispense(plate["A1"], vols=50, offsets=[Coordinate(0, 0, 10)]) - - Dispense a blue bucket (big container), with the first 4 channels (which will be spaced - equally apart): - - >>> await lh.dispense(blue_bucket, vols=50, use_channels=[0, 1, 2, 3]) - - Args: - wells: A list of resources to dispense liquid to. Can be a list of resources, or a single - resource, in which case all channels will dispense to that resource. - vols: A list of volumes to dispense, one for each channel, or a single volume to dispense to - all channels. If `vols` is a single number, then all channels will dispense that volume. In - units of ul. - use_channels: List of channels to use. Index from front to back. If `None`, the first - `len(channels)` channels will be used. - flow_rates: the flow rates, in ul/s. If `None`, the backend default will be used. - offsets: List of offsets for each channel, a translation that will be applied to the - dispense location. - liquid_height: The height of the liquid in the well wrt the bottom, in mm. - blow_out_air_volume: The volume of air to dispense after the liquid, in ul. If `None`, the - backend default will be used. - backend_kwargs: Additional keyword arguments for the backend, optional. - - Raises: - RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`. - - ValueError: If the dispense info is invalid, in other words, when all channels are `None`. - - ValueError: If all channels are `None`. - """ self._log_command( "dispense", @@ -1154,43 +829,6 @@ async def transfer( dispense_flow_rates: Optional[List[Optional[float]]] = None, **backend_kwargs, ): - """Transfer liquid from one well to another. - - Examples: - - Transfer 50 uL of liquid from the first well to the second well: - - >>> await lh.transfer(plate["A1"], plate["B1"], source_vol=50) - - Transfer 80 uL of liquid from the first well equally to the first column: - - >>> await lh.transfer(plate["A1"], plate["A1:H1"], source_vol=80) - - Transfer 60 uL of liquid from the first well in a 1:2 ratio to 2 other wells: - - >>> await lh.transfer(plate["A1"], plate["B1:C1"], source_vol=60, ratios=[2, 1]) - - Transfer arbitrary volumes to the first column: - - >>> await lh.transfer(plate["A1"], plate["A1:H1"], target_vols=[3, 1, 4, 1, 5, 9, 6, 2]) - - Args: - source: The source well. - targets: The target wells. - source_vol: The volume to transfer from the source well. - ratios: The ratios to use when transferring liquid to the target wells. If not specified, then - the volumes will be distributed equally. - target_vols: The volumes to transfer to the target wells. If specified, `source_vols` and - `ratios` must be `None`. - aspiration_flow_rate: The flow rate to use when aspirating, in ul/s. If `None`, the backend - default will be used. - dispense_flow_rates: The flow rates to use when dispensing, in ul/s. If `None`, the backend - default will be used. Either a single flow rate for all channels, or a list of flow rates, - one for each target well. - - Raises: - RuntimeError: If the setup has not been run. See :meth:`~LiquidHandler.setup`. - """ self._log_command( "transfer", @@ -1235,25 +873,6 @@ async def transfer( @contextlib.contextmanager def use_channels(self, channels: List[int]): - """Temporarily use the specified channels as a default argument to `use_channels`. - - Examples: - Use channel index 2 for all liquid handling operations inside the context: - - >>> with lh.use_channels([2]): - ... await lh.pick_up_tips(tip_rack["A1"]) - ... await lh.aspirate(plate["A1"], 50) - ... await lh.dispense(plate["A1"], 50) - - This is equivalent to: - - >>> await lh.pick_up_tips(tip_rack["A1"], use_channels=[2]) - >>> await lh.aspirate(plate["A1"], 50, use_channels=[2]) - >>> await lh.dispense(plate["A1"], 50, use_channels=[2]) - - Within the context manager, you can override the default channels by specifying the - `use_channels` argument explicitly. - """ self._default_use_channels = channels if self._lh_cap is not None: @@ -1273,39 +892,6 @@ async def use_tips( channels: Optional[List[int]] = None, discard: bool = True, ): - """Temporarily pick up tips from the specified tip spots on the specified channels. - - This is a convenience method that picks up tips from `tip_spots` on `channels` when entering - the context, and discards them when exiting the context. When passing `discard=False`, the tips - will be returned instead of discarded. - - Examples: - Use tips from A1 to H1 on channels 0 to 7, then discard: - - >>> with lh.use_tips(tip_rack["A1":"H1"], channels=list(range(8))): - ... await lh.aspirate(plate["A1":"H1"], vols=[50]*8) - ... await lh.dispense(plate["A1":"H1"], vols=[50]*8) - - This is equivalent to: - - >>> await lh.pick_up_tips(tip_rack["A1":"H1"], use_channels=list(range(8))) - >>> await lh.aspirate(plate["A1":"H1"], vols=[50]*8, use_channels=list(range(8))) - >>> await lh.dispense(plate["A1":"H1"], vols=[50]*8, use_channels=list(range(8))) - >>> await lh.discard_tips(use_channels=list(range(8))) - - Use tips from A1 to H1 on channels 0 to 7, but return them instead of discarding: - - >>> with lh.use_tips(tip_rack["A1":"H1"], channels=list(range(8)), discard=False): - ... await lh.aspirate(plate["A1":"H1"], vols=[50]*8) - ... await lh.dispense(plate["A1":"H1"], vols=[50]*8) - - This is equivalent to: - - >>> await lh.pick_up_tips(tip_rack["A1":"H1"], use_channels=list(range(8))) - >>> await lh.aspirate(plate["A1":"H1"], vols=[50]*8, use_channels=list(range(8))) - >>> await lh.dispense(plate["A1":"H1"], vols=[50]*8, use_channels=list(range(8))) - >>> await lh.return_tips(use_channels=list(range(8))) - """ if channels is None: channels = list(range(len(tip_spots))) @@ -1328,19 +914,6 @@ async def pick_up_tips96( offset: Coordinate = Coordinate.zero(), **backend_kwargs, ): - """Pick up tips using the 96 head. This will pick up 96 tips. - - Examples: - Pick up tips from a 96-tip tiprack: - - >>> await lh.pick_up_tips96(my_tiprack) - - Args: - tip_rack: The tip rack to pick up tips from. - offset: Additional offset to use when picking up tips. This is added to - :attr:`default_offset_head96`. - backend_kwargs: Additional keyword arguments for the backend, optional. - """ offset = self.default_offset_head96 + offset @@ -1370,26 +943,6 @@ async def drop_tips96( allow_nonzero_volume: bool = False, **backend_kwargs, ): - """Drop tips using the 96 head. This will drop 96 tips. - - Examples: - Drop tips to a 96-tip tiprack: - - >>> await lh.drop_tips96(my_tiprack) - - Drop tips to the trash: - - >>> await lh.drop_tips96(lh.deck.get_trash_area96()) - - Args: - resource: The tip rack to drop tips to. - offset: Additional offset to use when dropping tips. This is added to - :attr:`default_offset_head96`. - allow_nonzero_volume: If `True`, the tip will be dropped even if its volume is not zero (there - is liquid in the tip). If `False`, a RuntimeError will be raised if the tip has nonzero - volume. - backend_kwargs: Additional keyword arguments for the backend, optional. - """ offset = self.default_offset_head96 + offset @@ -1415,10 +968,6 @@ async def drop_tips96( ) def _get_96_head_origin_tip_rack(self) -> Optional[TipRack]: - """Get the tip rack where the tips on the 96 head were picked up. If no tips were picked up, - return `None`. If different tip racks were found for different tips on the head, raise a - RuntimeError.""" - tip_spot = self.head96[0].get_tip_origin() if tip_spot is None: return None @@ -1441,17 +990,6 @@ async def return_tips96( offset: Coordinate = Coordinate.zero(), **backend_kwargs, ): - """Return the tips on the 96 head to the tip rack where they were picked up. - - Examples: - Return the tips on the 96 head to the tip rack where they were picked up: - - >>> await lh.pick_up_tips96(my_tiprack) - >>> await lh.return_tips96() - - Raises: - RuntimeError: If no tips have been picked up. - """ self._log_command( "return_tips96", @@ -1469,24 +1007,6 @@ async def return_tips96( ) async def discard_tips96(self, allow_nonzero_volume: bool = True, **backend_kwargs): - """Permanently discard tips from the 96 head in the trash. This method only works when this - LiquidHandler is configured with a deck that implements the `get_trash_area96` method. - Otherwise, an `ImplementationError` will be raised. - - Examples: - Discard the tips on the 96 head: - - >>> await lh.discard_tips96() - - Args: - allow_nonzero_volume: If `True`, the tip will be dropped even if its volume is not zero (there - is liquid in the tip). If `False`, a RuntimeError will be raised if the tip has nonzero - volume. - backend_kwargs: Additional keyword arguments for the backend, optional. - - Raises: - ImplementationError: If the deck does not implement the `get_trash_area96` method. - """ self._log_command( "discard_tips96", @@ -1500,8 +1020,6 @@ async def discard_tips96(self, allow_nonzero_volume: bool = True, **backend_kwar ) def _check_96_head_fits_in_container(self, container: Container) -> bool: - """Check if the 96 head can fit in the given container.""" - tip_width = 2 # approximation distance_between_tips = 9 @@ -1521,25 +1039,6 @@ async def aspirate96( mix: Optional[Mix] = None, **backend_kwargs, ): - """Aspirate from all wells in a plate or from a container of a sufficient size. - - Examples: - Aspirate an entire 96 well plate or a container of sufficient size: - - >>> await lh.aspirate96(plate, volume=50) - >>> await lh.aspirate96(container, volume=50) - - Args: - resource: Resource object or list of wells. - volume: The volume to aspirate through each channel - offset: Adjustment to where the 96 head should go to aspirate relative to where the plate or container is defined to be. Added to :attr:`default_offset_head96`. Defaults to :func:`Coordinate.zero`. - flow_rate: The flow rate to use when aspirating, in ul/s. If `None`, the - backend default will be used. - liquid_height: The height of the liquid in the well wrt the bottom, in mm. If `None`, the backend default will be used. - blow_out_air_volume: The volume of air to aspirate after the liquid, in ul. If `None`, the backend default will be used. - mix: A mix operation to perform after the aspiration, optional. - backend_kwargs: Additional keyword arguments for the backend, optional. - """ offset = self.default_offset_head96 + offset @@ -1585,23 +1084,6 @@ async def dispense96( mix: Optional[Mix] = None, **backend_kwargs, ): - """Dispense to all wells in a plate. - - Examples: - Dispense an entire 96 well plate: - - >>> await lh.dispense96(plate, volume=50) - - Args: - resource: Resource object or list of wells. - volume: The volume to dispense through each channel - offset: Adjustment to where the 96 head should go to aspirate relative to where the plate or container is defined to be. Added to :attr:`default_offset_head96`. Defaults to :func:`Coordinate.zero`. - flow_rate: The flow rate to use when dispensing, in ul/s. If `None`, the backend default will be used. - liquid_height: The height of the liquid in the well wrt the bottom, in mm. If `None`, the backend default will be used. - blow_out_air_volume: The volume of air to dispense after the liquid, in ul. If `None`, the backend default will be used. - mix: If provided, the tip will mix after dispensing. - backend_kwargs: Additional keyword arguments for the backend, optional. - """ offset = self.default_offset_head96 + offset @@ -1644,17 +1126,6 @@ async def stamp( aspiration_flow_rate: Optional[float] = None, dispense_flow_rate: Optional[float] = None, ): - """Stamp (aspiration and dispense) one plate onto another. - - Args: - source: the source plate - target: the target plate - volume: the volume to be transported - aspiration_flow_rate: the flow rate for the aspiration, in ul/s. If `None`, the backend - default will be used. - dispense_flow_rate: the flow rate for the dispense, in ul/s. If `None`, the backend default - will be used. - """ self._log_command( "stamp", @@ -1694,14 +1165,8 @@ async def pick_up_resource( if pickup_distance_from_top is None: if resource.preferred_pickup_location is not None: - logger.debug( - f"Using preferred pickup location for resource {resource.name} as pickup_distance_from_top was not specified." - ) pickup_distance_from_top = resource.get_size_z() - resource.preferred_pickup_location.z else: - logger.debug( - f"No preferred pickup location for resource {resource.name}. Using default pickup distance of 5mm." - ) pickup_distance_from_top = 5.0 if self._resource_pickup is not None: @@ -1738,15 +1203,6 @@ async def move_picked_up_resource( direction: Optional[GripDirection] = None, **backend_kwargs, ): - """Move a resource that has been picked up to a new location. - - Args: - to: The new location to move the resource to. (LFB of plate) - offset: The offset to apply to the new location. - direction: The direction in which the resource is gripped. If `None`, the current direction - will be used. - backend_kwargs: Additional keyword arguments for the backend, optional. - """ self._log_command( "move_picked_up_resource", @@ -1944,25 +1400,6 @@ async def move_resource( drop_direction: GripDirection = GripDirection.FRONT, **backend_kwargs, ): - """Move a resource to a new location. - - Has convenience methods :meth:`move_plate` and :meth:`move_lid`. - - Examples: - Move a plate to a new location: - - >>> await lh.move_resource(plate, to=Coordinate(100, 100, 100)) - - Args: - resource: The Resource object. - to: The absolute coordinate (meaning relative to deck) to move the resource to. - intermediate_locations: A list of intermediate locations to move the resource through. - pickup_offset: The offset from the resource's origin, optional (rarely necessary). - destination_offset: The offset from the location's origin, optional (rarely necessary). - pickup_distance_from_top: The distance from the top of the resource to pick up from. - pickup_direction: The direction from which to pick up the resource. - drop_direction: The direction from which to put down the resource. - """ self._log_command( "move_resource", @@ -2022,29 +1459,6 @@ async def move_lid( pickup_distance_from_top: float = 5.7 - 3.33, **backend_kwargs, ): - """Move a lid to a new location. - - A convenience method for :meth:`move_resource`. - - Examples: - Move a lid to the :class:`~resources.ResourceStack`: - - >>> await lh.move_lid(plate.lid, stacking_area) - - Move a lid to the stacking area and back, grabbing it from the left side: - - >>> await lh.move_lid(plate.lid, stacking_area, pickup_direction=GripDirection.LEFT) - >>> await lh.move_lid(stacking_area.get_top_item(), plate, drop_direction=GripDirection.LEFT) - - Args: - lid: The lid to move. Can be either a Plate object or a Lid object. - to: The location to move the lid to, either a plate, ResourceStack or a Coordinate. - pickup_offset: The offset from the resource's origin, optional (rarely necessary). - destination_offset: The offset from the location's origin, optional (rarely necessary). - - Raises: - ValueError: If the lid is not assigned to a resource. - """ self._log_command( "move_lid", @@ -2082,37 +1496,6 @@ async def move_plate( pickup_distance_from_top: float = 13.2 - 3.33, **backend_kwargs, ): - """Move a plate to a new location. - - A convenience method for :meth:`move_resource`. - - Examples: - Move a plate to into a carrier spot: - - >>> await lh.move_plate(plate, plt_car[1]) - - Move a plate to an absolute location: - - >>> await lh.move_plate(plate_01, Coordinate(100, 100, 100)) - - Move a lid to another carrier spot, grabbing it from the left side: - - >>> await lh.move_plate(plate, plt_car[1], pickup_direction=GripDirection.LEFT) - >>> await lh.move_plate(plate, plt_car[0], drop_direction=GripDirection.LEFT) - - Move a resource while visiting a few intermediate locations along the way: - - >>> await lh.move_plate(plate, plt_car[1], intermediate_locations=[ - ... Coordinate(100, 100, 100), - ... Coordinate(200, 200, 200), - ... ]) - - Args: - plate: The plate to move. Can be either a Plate object or a ResourceHolder object. - to: The location to move the plate to, either a plate, ResourceHolder or a Coordinate. - pickup_offset: The offset from the resource's origin, optional (rarely necessary). - destination_offset: The offset from the location's origin, optional (rarely necessary). - """ self._log_command( "move_plate", @@ -2147,12 +1530,6 @@ def serialize(self) -> dict: @classmethod def deserialize(cls, data: dict, allow_marshal: bool = False) -> LiquidHandler: - """Deserialize a liquid handler from a dictionary. - - Args: - data: A dictionary representation of the liquid handler. - """ - deck_data = data["children"][0] deck = Deck.deserialize(data=deck_data, allow_marshal=allow_marshal) backend = LiquidHandlerBackend.deserialize(data=data["backend"]) @@ -2171,12 +1548,6 @@ def deserialize(cls, data: dict, allow_marshal: bool = False) -> LiquidHandler: @classmethod def load(cls, path: str) -> LiquidHandler: - """Load a liquid handler from a file. - - Args: - path: The path to the file to load from. - """ - with open(path, "r", encoding="utf-8") as f: return cls.deserialize(json.load(f)) @@ -2190,19 +1561,16 @@ async def prepare_for_manual_channel_operation(self, channel: int): await self.backend.prepare_for_manual_channel_operation(channel=channel) async def move_channel_x(self, channel: int, x: float): - """Move channel to absolute x position""" self._log_command("move_channel_x", channel=channel, x=x) assert 0 <= channel < self.backend.num_channels, f"Invalid channel: {channel}" await self.backend.move_channel_x(channel=channel, x=x) async def move_channel_y(self, channel: int, y: float): - """Move channel to absolute y position""" self._log_command("move_channel_y", channel=channel, y=y) assert 0 <= channel < self.backend.num_channels, f"Invalid channel: {channel}" await self.backend.move_channel_y(channel=channel, y=y) async def move_channel_z(self, channel: int, z: float): - """Move channel to absolute z position""" self._log_command("move_channel_z", channel=channel, z=z) assert 0 <= channel < self.backend.num_channels, f"Invalid channel: {channel}" await self.backend.move_channel_z(channel=channel, z=z) @@ -2215,7 +1583,6 @@ def assign_child_resource( location: Optional[Coordinate], reassign: bool = True, ): - """Not implement on LiquidHandler, since the deck is managed by the :attr:`deck` attribute.""" raise NotImplementedError( "Cannot assign child resource to liquid handler. Use lh.deck.assign_child_resource() instead." ) @@ -2223,15 +1590,6 @@ def assign_child_resource( async def probe_tip_presence_via_pickup( self, tip_spots: List[TipSpot], use_channels: Optional[List[int]] = None ) -> Dict[str, bool]: - """Probe tip presence by attempting pickup on each TipSpot. - - Args: - tip_spots: TipSpots to probe. - use_channels: Channels to use (must match tip_spots length). - - Returns: - Dict[str, bool]: Mapping of tip spot names to presence flags. - """ if use_channels is None: use_channels = list(range(len(tip_spots))) @@ -2303,28 +1661,6 @@ async def probe_tip_inventory( probing_fn: Optional[TipPresenceProbingMethod] = None, use_channels: Optional[List[int]] = None, ) -> Dict[str, bool]: - """Probe the presence of tips in multiple tip spots. - - The provided ``probing_fn`` is used for probing batches of tip spots. The - default uses :meth:`probe_tip_presence_via_pickup`. - - Examples: - Probe all tip spots in one or more tip racks. - - >>> import pylabrobot.resources.functional as F - >>> spots = F.get_all_tip_spots([tip_rack_1, tip_rack_2]) - >>> presence = await lh.probe_tip_inventory(spots) - - Args: - tip_spots: - Tip spots to probe for presence of a tip. - probing_fn: - Function used to probe a batch of tip spots. Must accept ``tip_spots`` and - ``use_channels`` and return a mapping of tip spot names to boolean flags. - - Returns: - Mapping from tip spot names to whether a tip is present. - """ if probing_fn is None: probing_fn = self.probe_tip_presence_via_pickup @@ -2346,24 +1682,8 @@ async def probe_tip_inventory( async def consolidate_tip_inventory( self, tip_racks: List[TipRack], use_channels: Optional[List[int]] = None ): - """ - Consolidate partial tip racks on the deck by redistributing tips. - - This function identifies partially-filled tip racks (excluding any in - `ignore_tiprack_list`) in the 'tip_inventory`, the subset of the deck tree - that is of type TipRack, and consolidates their tips into as few tip racks - as possible, grouped by tip model. - Tips are moved efficiently to minimize pipetting steps, avoiding redundant - visits to the same drop columns. - - Args: - tip_racks: List of TipRack objects to consolidate. - use_channels: Optional list of channels to use for consolidation. If not - provided, the first 8 available channels will be used. - """ def merge_sublists(lists: List[List[TipSpot]], max_len: int) -> List[List[TipSpot]]: - """Merge adjacent sublists if combined length <= max_len, without splitting sublists.""" merged: List[List[TipSpot]] = [] buffer: List[TipSpot] = [] @@ -2386,15 +1706,6 @@ def merge_sublists(lists: List[List[TipSpot]], max_len: int) -> List[List[TipSpo def divide_list_into_chunks( list_l: List[TipSpot], chunk_size: int ) -> Generator[List[TipSpot], None, None]: - """Divides a list into smaller chunks of a specified size. - - Parameters: - - list_l: The list to be divided into chunks. - - chunk_size: The size of each chunk. - - Returns: - A generator that yields chunks of the list. - """ for i in range(0, len(list_l), chunk_size): yield list_l[i : i + chunk_size] @@ -2463,7 +1774,6 @@ def divide_list_into_chunks( # 4: Cluster target tip_spots by BOTH parent tip_rack & x-coordinate def key_for_tip_spot(tip_spot: TipSpot) -> Tuple[str, float]: - """Key function to sort tip spots by parent name and x-coordinate.""" assert tip_spot.parent is not None and tip_spot.location is not None return (tip_spot.parent.name, round(tip_spot.location.x, 3)) diff --git a/pylabrobot/legacy/plate_reading/plate_reader.py b/pylabrobot/legacy/plate_reading/plate_reader.py index adfc2584627..c0796f53e68 100644 --- a/pylabrobot/legacy/plate_reading/plate_reader.py +++ b/pylabrobot/legacy/plate_reading/plate_reader.py @@ -1,14 +1,122 @@ import logging from typing import Dict, List, Optional, cast +from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability +from pylabrobot.capabilities.plate_reading.absorbance.backend import ( + AbsorbanceBackend as _NewAbsorbanceBackend, +) +from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult +from pylabrobot.capabilities.plate_reading.fluorescence import FluorescenceCapability +from pylabrobot.capabilities.plate_reading.fluorescence.backend import ( + FluorescenceBackend as _NewFluorescenceBackend, +) +from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult +from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability +from pylabrobot.capabilities.plate_reading.luminescence.backend import ( + LuminescenceBackend as _NewLuminescenceBackend, +) +from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult +from pylabrobot.legacy._backend_params import _DictBackendParams from pylabrobot.legacy.machines.machine import Machine, need_setup_finished from pylabrobot.legacy.plate_reading.backend import PlateReaderBackend from pylabrobot.legacy.plate_reading.standard import NoPlateError from pylabrobot.resources import Coordinate, Plate, Resource, ResourceHolder, Rotation, Well +from pylabrobot.serializer import SerializableMixin logger = logging.getLogger(__name__) +class _AbsorbanceAdapter(_NewAbsorbanceBackend): + """Adapts PlateReaderBackend.read_absorbance to AbsorbanceBackend.""" + + def __init__(self, legacy: PlateReaderBackend): + self._legacy = legacy + + async def read_absorbance( + self, + plate: Plate, + wells: List[Well], + wavelength: int, + backend_params: Optional[SerializableMixin] = None, + ) -> List[AbsorbanceResult]: + kwargs = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + dicts = await self._legacy.read_absorbance( + plate=plate, wells=wells, wavelength=wavelength, **kwargs + ) + return [ + AbsorbanceResult( + data=d["data"], + wavelength=d["wavelength"], + temperature=d.get("temperature"), + timestamp=d.get("time", 0), + ) + for d in dicts + ] + + +class _LuminescenceAdapter(_NewLuminescenceBackend): + """Adapts PlateReaderBackend.read_luminescence to LuminescenceBackend.""" + + def __init__(self, legacy: PlateReaderBackend): + self._legacy = legacy + + async def read_luminescence( + self, + plate: Plate, + wells: List[Well], + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[LuminescenceResult]: + kwargs = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + dicts = await self._legacy.read_luminescence( + plate=plate, wells=wells, focal_height=focal_height, **kwargs + ) + return [ + LuminescenceResult( + data=d["data"], + temperature=d.get("temperature"), + timestamp=d.get("time", 0), + ) + for d in dicts + ] + + +class _FluorescenceAdapter(_NewFluorescenceBackend): + """Adapts PlateReaderBackend.read_fluorescence to FluorescenceBackend.""" + + def __init__(self, legacy: PlateReaderBackend): + self._legacy = legacy + + async def read_fluorescence( + self, + plate: Plate, + wells: List[Well], + excitation_wavelength: int, + emission_wavelength: int, + focal_height: float, + backend_params: Optional[SerializableMixin] = None, + ) -> List[FluorescenceResult]: + kwargs = backend_params.kwargs if isinstance(backend_params, _DictBackendParams) else {} + dicts = await self._legacy.read_fluorescence( + plate=plate, + wells=wells, + excitation_wavelength=excitation_wavelength, + emission_wavelength=emission_wavelength, + focal_height=focal_height, + **kwargs, + ) + return [ + FluorescenceResult( + data=d["data"], + excitation_wavelength=d["ex_wavelength"], + emission_wavelength=d["em_wavelength"], + temperature=d.get("temperature"), + timestamp=d.get("time", 0), + ) + for d in dicts + ] + + class PlateReader(ResourceHolder, Machine): """The front end for plate readers. Plate readers are devices that can read luminescence, absorbance, or fluorescence from a plate. @@ -53,6 +161,22 @@ def __init__( Machine.__init__(self, backend=backend) self.backend: PlateReaderBackend = backend # fix type + self._absorbance_cap = AbsorbanceCapability(backend=_AbsorbanceAdapter(backend)) + self._luminescence_cap = LuminescenceCapability(backend=_LuminescenceAdapter(backend)) + self._fluorescence_cap = FluorescenceCapability(backend=_FluorescenceAdapter(backend)) + + async def setup(self, **backend_kwargs): + await super().setup(**backend_kwargs) + await self._absorbance_cap._on_setup() + await self._luminescence_cap._on_setup() + await self._fluorescence_cap._on_setup() + + async def stop(self): + await self._fluorescence_cap._on_stop() + await self._luminescence_cap._on_stop() + await self._absorbance_cap._on_stop() + await super().stop() + def assign_child_resource( self, resource: Resource, @@ -100,12 +224,21 @@ async def read_luminescence( "data": List[List[float]] """ - result = await self.backend.read_luminescence( - plate=self.get_plate(), - wells=wells or self.get_plate().get_all_items(), + plate = self.get_plate() + results = await self._luminescence_cap.read( + plate=plate, + wells=wells or plate.get_all_items(), focal_height=focal_height, - **backend_kwargs, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, ) + result = [ + { + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] if not use_new_return_type: logger.warning( @@ -137,12 +270,22 @@ async def read_absorbance( "data": List[List[float]] """ - result = await self.backend.read_absorbance( - plate=self.get_plate(), - wells=wells or self.get_plate().get_all_items(), + plate = self.get_plate() + results = await self._absorbance_cap.read( + plate=plate, + wells=wells or plate.get_all_items(), wavelength=wavelength, - **backend_kwargs, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, ) + result = [ + { + "wavelength": r.wavelength, + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] if not use_new_return_type: logger.warning( @@ -184,14 +327,26 @@ async def read_fluorescence( "Excitation wavelength is greater than emission wavelength. This is unusual and may indicate an error." ) - result = await self.backend.read_fluorescence( - plate=self.get_plate(), - wells=wells or self.get_plate().get_all_items(), + plate = self.get_plate() + results = await self._fluorescence_cap.read( + plate=plate, + wells=wells or plate.get_all_items(), excitation_wavelength=excitation_wavelength, emission_wavelength=emission_wavelength, focal_height=focal_height, - **backend_kwargs, + backend_params=_DictBackendParams(kwargs=backend_kwargs) if backend_kwargs else None, ) + result = [ + { + "ex_wavelength": r.excitation_wavelength, + "em_wavelength": r.emission_wavelength, + "time": r.timestamp, + "temperature": r.temperature, + "data": r.data, + } + for r in results + ] + if not use_new_return_type: logger.warning( "The return type of read_fluorescence will change in a future version. Please set " From be81047545f2d1064e4576aee834c40dac05f0f8 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 28 Mar 2026 21:22:20 -0700 Subject: [PATCH 20/69] Extract STAR subsystems and generic driver methods from legacy backend Move autoload, cover, x-arm, wash station, and ~44 generic driver infrastructure methods (firmware queries, EEPROM, area reservation, configuration) into the new STARDriver architecture. Legacy backend methods now delegate to new classes or have deprecation docstrings. - STARAutoload: autoload module control (carrier loading, barcode, LEDs) - STARCover: front cover lock/unlock/enable/disable - STARXArm: left/right X-arm positioning (parameterized by side) - STARWashStation: dual-chamber wash station drain/fill/init - STARDriver: generic instrument operations directly on driver - STARChatterboxDriver: updated with all subsystems - STAR device only exposes capabilities (PIP, Head96, iSWAP) - Subsystems live on the driver, accessed via star._driver - 114 tests across all subsystems - Right X-arm and wash station are conditional on hardware config - X-arm methods use mm (PLR standard), not 0.1mm firmware units - Fixed pre-existing assertion bugs in release_occupied_area and set_instrument_configuration Co-Authored-By: Claude Opus 4.6 (1M context) --- .../hamilton/liquid_handlers/__init__.py | 0 pylabrobot/hamilton/liquid_handlers/base.py | 517 +++++ .../hamilton/liquid_handlers/star/__init__.py | 5 + .../liquid_handlers/star/chatterbox.py | 133 ++ .../hamilton/liquid_handlers/star/core.py | 14 +- .../hamilton/liquid_handlers/star/cover.py | 65 + .../hamilton/liquid_handlers/star/driver.py | 991 +++++++++ .../liquid_handlers/star/head96_backend.py | 468 ++++ .../hamilton/liquid_handlers/star/iswap.py | 27 +- .../liquid_handlers/star/misc/architecture.md | 300 +++ .../star/{ => misc}/core_test.ipynb | 0 .../liquid_handlers/star/misc/demo.ipynb | 665 ++++++ .../star/{ => misc}/iswap_test.ipynb | 0 .../liquid_handlers/star/pip_backend.py | 1034 +++++++++ .../hamilton/liquid_handlers/star/star.py | 121 ++ .../liquid_handlers/star/tests/__init__.py | 0 .../star/{ => tests}/autoload_tests.py | 99 + .../star/{ => tests}/core_tests.py | 20 +- .../liquid_handlers/star/tests/cover_tests.py | 68 + .../star/{ => tests}/iswap_tests.py | 30 +- .../star/tests/legacy_parity_tests.py | 191 ++ .../star/tests/wash_station_tests.py | 210 ++ .../liquid_handlers/star/tests/x_arm_tests.py | 153 ++ .../liquid_handlers/star/wash_station.py | 118 + .../hamilton/liquid_handlers/star/x_arm.py | 93 + .../backends/hamilton/STAR_backend.py | 1892 +++-------------- 26 files changed, 5626 insertions(+), 1588 deletions(-) create mode 100644 pylabrobot/hamilton/liquid_handlers/__init__.py create mode 100644 pylabrobot/hamilton/liquid_handlers/base.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/__init__.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/chatterbox.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/cover.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/driver.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/head96_backend.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md rename pylabrobot/hamilton/liquid_handlers/star/{ => misc}/core_test.ipynb (100%) create mode 100644 pylabrobot/hamilton/liquid_handlers/star/misc/demo.ipynb rename pylabrobot/hamilton/liquid_handlers/star/{ => misc}/iswap_test.ipynb (100%) create mode 100644 pylabrobot/hamilton/liquid_handlers/star/pip_backend.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/star.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/tests/__init__.py rename pylabrobot/hamilton/liquid_handlers/star/{ => tests}/autoload_tests.py (71%) rename pylabrobot/hamilton/liquid_handlers/star/{ => tests}/core_tests.py (85%) create mode 100644 pylabrobot/hamilton/liquid_handlers/star/tests/cover_tests.py rename pylabrobot/hamilton/liquid_handlers/star/{ => tests}/iswap_tests.py (82%) create mode 100644 pylabrobot/hamilton/liquid_handlers/star/tests/legacy_parity_tests.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/tests/wash_station_tests.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/tests/x_arm_tests.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/wash_station.py create mode 100644 pylabrobot/hamilton/liquid_handlers/star/x_arm.py diff --git a/pylabrobot/hamilton/liquid_handlers/__init__.py b/pylabrobot/hamilton/liquid_handlers/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/liquid_handlers/base.py b/pylabrobot/hamilton/liquid_handlers/base.py new file mode 100644 index 00000000000..4f5b361725f --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/base.py @@ -0,0 +1,517 @@ +import asyncio +import datetime +import logging +import threading +import time +import warnings +from abc import ABCMeta, abstractmethod +from dataclasses import dataclass +from typing import ( + Any, + List, + Optional, + Sequence, + Tuple, + TypeVar, +) + +from pylabrobot.device import Driver +from pylabrobot.io.usb import USB +from pylabrobot.legacy.liquid_handling.standard import PipettingOp +from pylabrobot.resources import TipSpot +from pylabrobot.resources.hamilton import ( + HamiltonTip, + TipPickupMethod, + TipSize, +) + +T = TypeVar("T") + +logger = logging.getLogger("pylabrobot") + + +@dataclass +class HamiltonTask: + """A command that has been sent, awaiting a response.""" + + id_: Optional[int] + loop: asyncio.AbstractEventLoop + fut: asyncio.Future + cmd: str + timeout_time: float + + +class HamiltonLiquidHandler(Driver, metaclass=ABCMeta): + """ + Abstract base class for Hamilton liquid handling robot backends. + """ + + @abstractmethod + def __init__( + self, + id_product: int, + device_address: Optional[int] = None, + serial_number: Optional[str] = None, + packet_read_timeout: int = 3, + read_timeout: int = 30, + write_timeout: int = 30, + ): + """ + Args: + device_address: The USB address of the Hamilton device. Only useful if using more than one + Hamilton device. + serial_number: The serial number of the Hamilton device. Only useful if using more than one + Hamilton device. + packet_read_timeout: The timeout for reading packets from the Hamilton machine in seconds. + read_timeout: The timeout for from the Hamilton machine in seconds. + num_channels: the number of pipette channels present on the robot. + """ + + super().__init__() + self.io = USB( + human_readable_device_name="Hamilton Liquid Handler", + id_vendor=0x08AF, + id_product=id_product, + device_address=device_address, + write_timeout=write_timeout, + serial_number=serial_number, + ) + self.packet_read_timeout = packet_read_timeout + self.read_timeout = read_timeout + + self.id_ = 0 + + self._reading_thread: Optional[threading.Thread] = None + self._reading_thread_stop = threading.Event() + self._waiting_tasks: List[HamiltonTask] = [] + self._tth2tti: dict[int, int] = {} # hash to tip type index + + def __setattr__(self, name: str, value: Any) -> None: + if name == "allow_firmware_planning": + warnings.warn( + "allow_firmware_planning is deprecated and will be removed in a future version. " + "The behavior is now always enabled.", + DeprecationWarning, + stacklevel=2, + ) + return + super().__setattr__(name, value) + + async def setup(self): + await super().setup() + await self.io.setup() + self._reading_thread_stop.clear() + self._reading_thread = threading.Thread(target=self._reading_thread_main, daemon=True) + self._reading_thread.start() + + async def stop(self): + self._reading_thread_stop.set() + if self._reading_thread is not None: + self._reading_thread.join(timeout=10) + self._reading_thread = None + for task in self._waiting_tasks: + task.loop.call_soon_threadsafe( + task.fut.set_exception, RuntimeError("Stopping HamiltonLiquidHandler.") + ) + self._waiting_tasks.clear() + self._tth2tti.clear() + await self.io.stop() + + def serialize(self) -> dict: + usb_serialized = self.io.serialize() + del usb_serialized["id_vendor"] + del usb_serialized["id_product"] + del usb_serialized["human_readable_device_name"] + return {**super().serialize(), **usb_serialized} + + @property + @abstractmethod + def module_id_length(self) -> int: + """The length of the module identifier in firmware commands.""" + + def _generate_id(self) -> int: + """continuously generate unique ids 0 <= x < 10000.""" + self.id_ += 1 + return self.id_ % 10000 + + def _to_list(self, val: List[T], tip_pattern: List[bool]) -> List[T]: + """Convert a list of values to a list of values with the correct length. + + This is roughly one-hot encoding. STAR expects a value for a list parameter at the position + for the corresponding channel. If `tip_pattern` is False, there, the value itself is ignored, + but it must be present. + + Args: + val: A list of values, exactly one for each channel that is involved in the operation. + tip_pattern: A list of booleans indicating whether a channel is involved in the operation. + + Returns: + A list of values with the correct length. Each value that is not involved in the operation + is set to the first value in `val`, which is ignored by STAR. + """ + + # use the default value if a channel is not involved, otherwise use the value in val + assert len(val) > 0 + assert len(val) <= len(tip_pattern) + + result: List[T] = [] + arg_index = 0 + for channel_involved in tip_pattern: + if channel_involved: + if arg_index >= len(val): + raise ValueError(f"Too few values for tip pattern {tip_pattern}: {val}") + result.append(val[arg_index]) + arg_index += 1 + else: + # this value will be ignored, so just use a value we know is valid + result.append(val[0]) + if arg_index < len(val): + raise ValueError(f"Too many values for tip pattern {tip_pattern}: {val}") + return result + + def _assemble_command( + self, + module: str, + command: str, + auto_id: bool, + tip_pattern: Optional[List[bool]], + **kwargs, + ) -> Tuple[str, Optional[int]]: + """Assemble a firmware command to the Hamilton machine. + + Args: + module: 2 character module identifier (C0 for master, ...) + command: 2 character command identifier (QM for request status, ...) + tip_pattern: A list of booleans indicating whether a channel is involved in the operation. + This value will be used to convert the list values in kwargs to the correct length. + kwargs: any named parameters. the parameter name should also be 2 characters long. The value + can be any size. + + Returns: + A string containing the assembled command. + """ + + cmd = module + command + if auto_id: + cmd_id = self._generate_id() + cmd += f"id{cmd_id:04}" # id has to be the first param + else: + cmd_id = None + + for k, v in kwargs.items(): + if isinstance(v, datetime.datetime): + v = v.strftime("%Y-%m-%d %h:%M") + elif isinstance(v, bool): + v = 1 if v else 0 + elif isinstance(v, list): + # If this command is 'one-hot' encoded, for the channels, then the list should be the + # same length as the 'one-hot' encoding key (tip_pattern.) If the list is shorter than + # that, it will be 'one-hot encoded automatically. Note that this may raise an error if + # the number of values provided is not the same as the number of channels used. + if tip_pattern is not None: + if len(v) != len(tip_pattern): + # convert one-hot encoded list to int list + v = self._to_list(v, tip_pattern) + # list is now of length len(tip_pattern) + if isinstance(v[0], bool): # convert bool list to int list + v = [int(x) for x in v] + v = " ".join([str(e) for e in v]) + ("&" if len(v) < self.num_channels else "") + if k.endswith("_"): # workaround for kwargs named in, as, ... + k = k[:-1] + assert len(k) == 2, "Keyword arguments should be 2 characters long, but got: " + k + cmd += f"{k}{v}" + + return cmd, cmd_id + + async def send_command( + self, + module: str, + command: str, + auto_id=True, + tip_pattern: Optional[List[bool]] = None, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait=True, + fmt: Optional[Any] = None, + **kwargs, + ): + """Send a firmware command to the Hamilton machine. + + Args: + module: 2 character module identifier (C0 for master, ...) + command: 2 character command identifier (QM for request status) + auto_id: auto generate id if True, otherwise use the id in kwargs (or None if not present) + write_timeout: write timeout in seconds. If None, `self.write_timeout` is used. + read_timeout: read timeout in seconds. If None, `self.read_timeout` is used. + wait: If True, wait for a response. If False, return `None` immediately after sending the + command. + fmt: A format to use for the response. If `None`, the response is not parsed. + kwargs: any named parameters. The parameter name should also be 2 characters long. The value + can be of any size. + + Returns: + A dictionary containing the parsed response, or None if no response was read within `timeout`. + """ + + cmd, id_ = self._assemble_command( + module=module, + command=command, + tip_pattern=tip_pattern, + auto_id=auto_id, + **kwargs, + ) + resp = await self._write_and_read_command( + id_=id_, + cmd=cmd, + write_timeout=write_timeout, + read_timeout=read_timeout, + wait=wait, + ) + if resp is not None and fmt is not None: + return self._parse_response(resp, fmt) + return resp + + async def _write_and_read_command( + self, + id_: Optional[int], + cmd: str, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait: bool = True, + ) -> Optional[str]: + """Write a command to the Hamilton machine and read the response.""" + await self.io.write(cmd.encode(), timeout=write_timeout) + + if not wait: + return None + + # Attempt to read packets until timeout, or when we identify the right id. + if read_timeout is None: + read_timeout = self.read_timeout + + loop = asyncio.get_event_loop() + fut: asyncio.Future[str] = loop.create_future() + self._start_reading(id_, loop, fut, cmd, read_timeout) + result = await fut + return result + + def _start_reading( + self, + id_: Optional[int], + loop: asyncio.AbstractEventLoop, + fut: asyncio.Future, + cmd: str, + timeout: int, + ) -> None: + """Submit a task to the reading thread.""" + + timeout_time = time.time() + timeout + self._waiting_tasks.append( + HamiltonTask(id_=id_, loop=loop, fut=fut, cmd=cmd, timeout_time=timeout_time) + ) + + if self._reading_thread is None or not self._reading_thread.is_alive(): + self._reading_thread_stop.clear() + self._reading_thread = threading.Thread(target=self._reading_thread_main, daemon=True) + self._reading_thread.start() + + @abstractmethod + def get_id_from_fw_response(self, resp: str) -> Optional[int]: + """Get the id from a firmware response.""" + + @abstractmethod + def check_fw_string_error(self, resp: str): + """Raise an error if the firmware response is an error response.""" + + @abstractmethod + def _parse_response(self, resp: str, fmt: Any) -> dict: + """Parse a firmware response.""" + + def _reading_thread_main(self) -> None: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(self._continuously_read()) + + async def _continuously_read(self) -> None: + """Continuously read from the USB port until stop is requested. + + Tasks are stored in the `self._waiting_tasks` list, and contain a future that will be + completed when the task is finished. Tasks are submitted to the list using the + `self._start_reading` method. + + On each iteration, read the USB port. If a response is received, parse it and check if it is + relevant to any of the tasks. If so, complete the future and remove the task from the + list. If a task has timed out, complete the future with a `TimeoutError`. + """ + + while not self._reading_thread_stop.is_set(): + for idx in range(len(self._waiting_tasks) - 1, -1, -1): # reverse order to allow deletion + task = self._waiting_tasks[idx] + if time.time() > task.timeout_time: + logger.warning("Timeout while waiting for response to command %s.", task.cmd) + task.loop.call_soon_threadsafe( + task.fut.set_exception, + TimeoutError(f"Timeout while waiting for response to command {task.cmd}."), + ) + del self._waiting_tasks[idx] + + if len(self._waiting_tasks) == 0: + await asyncio.sleep(0.01) + continue + + try: + resp = (await self.io.read()).decode("utf-8") + except TimeoutError: + continue + + if resp == "": + continue + + # Parse response. + try: + response_id = self.get_id_from_fw_response(resp) + except ValueError as e: + logger.warning("Could not parse response: %s (%s)", resp, e) + continue + + module_and_command = resp[: self.module_id_length + 2] + for idx in range(len(self._waiting_tasks)): + task = self._waiting_tasks[idx] + # if the command has no id, we have to check the command itself + if response_id == task.id_ or ( + task.id_ is None and task.cmd.startswith(module_and_command) + ): + try: + self.check_fw_string_error(resp) + except Exception as e: + task.loop.call_soon_threadsafe(task.fut.set_exception, e) + else: + task.loop.call_soon_threadsafe(task.fut.set_result, resp) + del self._waiting_tasks[idx] + break + + def _ops_to_fw_positions( + self, ops: Sequence[PipettingOp], use_channels: List[int] + ) -> Tuple[List[int], List[int], List[bool]]: + """use_channels is a list of channels to use. STAR expects this in one-hot encoding. This is + method converts that, and creates a matching list of x and y positions.""" + assert use_channels == sorted(use_channels), "Channels must be sorted." + + x_positions: List[int] = [] + y_positions: List[int] = [] + channels_involved: List[bool] = [] + for i, channel in enumerate(use_channels): + while channel > len(channels_involved): + channels_involved.append(False) + x_positions.append(0) + y_positions.append(0) + channels_involved.append(True) + + x_pos = ops[i].resource.get_location_wrt(self.deck, x="c", y="c", z="b").x + ops[i].offset.x + x_positions.append(round(x_pos * 10)) + + y_pos = ops[i].resource.get_location_wrt(self.deck, x="c", y="c", z="b").y + ops[i].offset.y + y_positions.append(round(y_pos * 10)) + + # check that the minimum d between any two y positions is >9mm + # O(n^2) search is not great but this is most readable, and the max size is 16, so it's fine. + for channel_idx1, (x1, y1) in enumerate(zip(x_positions, y_positions)): + for channel_idx2, (x2, y2) in enumerate(zip(x_positions, y_positions)): + if channel_idx1 == channel_idx2: + continue + if not channels_involved[channel_idx1] or not channels_involved[channel_idx2]: + continue + if x1 != x2: # channels not on the same column -> will be two operations on the machine + continue + if y1 != y2 and abs(y1 - y2) < 90: + raise ValueError( + f"Minimum distance between two y positions is <9mm: {y1}, {y2}" + f" (channel {channel_idx1} and {channel_idx2})" + ) + + if len(ops) > self.num_channels: + raise ValueError(f"Too many channels specified: {len(ops)} > {self.num_channels}") + + if len(x_positions) < self.num_channels: + # We do want to have a trailing zero on x_positions, y_positions, and channels_involved, for + # some reason, if the length < 8. + x_positions = x_positions + [0] + y_positions = y_positions + [0] + channels_involved = channels_involved + [False] + + return x_positions, y_positions, channels_involved + + @abstractmethod + async def define_tip_needle( + self, + tip_type_table_index: int, + has_filter: bool, + tip_length: int, + maximum_tip_volume: int, + tip_size: TipSize, + pickup_method: TipPickupMethod, + ): + """Tip/needle definition in firmware.""" + + async def get_or_assign_tip_type_index(self, tip: HamiltonTip) -> int: + """Get a tip type table index for the tip. + + If the tip has previously been defined, used that index. Otherwise, define a new tip type. + """ + + tip_type_hash = hash(tip) + + if tip_type_hash not in self._tth2tti: + ttti = len(self._tth2tti) + 1 + if ttti > 99: + raise ValueError("Too many tip types defined.") + + await self.define_tip_needle( + tip_type_table_index=ttti, + has_filter=tip.has_filter, + tip_length=round((tip.total_tip_length - tip.fitting_depth) * 10), # in 0.1mm + maximum_tip_volume=round(tip.maximal_volume * 10), # in 0.1ul + tip_size=tip.tip_size, + pickup_method=tip.pickup_method, + ) + self._tth2tti[tip_type_hash] = ttti + + return self._tth2tti[tip_type_hash] + + def _get_hamilton_tip(self, tip_spots: List[TipSpot]) -> HamiltonTip: + """Get the single tip type for all tip spots. If it does not exist or is not a HamiltonTip, + raise an error.""" + tips = set(tip_spot.get_tip() for tip_spot in tip_spots) + if len(tips) > 1: + raise ValueError("Cannot mix tips with different tip types.") + if len(tips) == 0: + raise ValueError("No tips specified.") + tip = tips.pop() + if not isinstance(tip, HamiltonTip): + raise ValueError(f"Tip {tip} is not a HamiltonTip.") + return tip + + async def send_raw_command( + self, + command: str, + write_timeout: Optional[int] = None, + read_timeout: Optional[int] = None, + wait: bool = True, + ) -> Optional[str]: + """Send a raw command to the machine.""" + id_index = command.find("id") + if id_index != -1: + id_str = command[id_index + 2 : id_index + 6] + if not id_str.isdigit(): + raise ValueError("Id must be a 4 digit int.") + id_ = int(id_str) + else: + id_ = None + + return await self._write_and_read_command( + id_=id_, + cmd=command, + write_timeout=write_timeout, + read_timeout=read_timeout, + wait=wait, + ) diff --git a/pylabrobot/hamilton/liquid_handlers/star/__init__.py b/pylabrobot/hamilton/liquid_handlers/star/__init__.py new file mode 100644 index 00000000000..a964ca5a9d2 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/__init__.py @@ -0,0 +1,5 @@ +from .autoload import STARAutoload +from .cover import STARCover +from .star import STAR +from .wash_station import STARWashStation +from .x_arm import STARXArm diff --git a/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py new file mode 100644 index 00000000000..7ee08e399a8 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py @@ -0,0 +1,133 @@ +"""STARChatterboxDriver: prints commands instead of sending them over USB.""" + +from typing import List, Optional + +from .driver import ( + DriveConfiguration, + ExtendedConfiguration, + MachineConfiguration, + STARDriver, +) + +_DEFAULT_MACHINE_CONF = MachineConfiguration( + pip_type_1000ul=True, + kb_iswap_installed=True, + auto_load_installed=True, + num_pip_channels=8, +) + +_DEFAULT_EXTENDED_CONF = ExtendedConfiguration( + left_x_drive_large=True, + iswap_gripper_wide=True, + instrument_size_slots=30, + auto_load_size_slots=30, + tip_waste_x_position=800.0, + left_x_drive=DriveConfiguration(iswap_installed=True, core_96_head_installed=True), + min_iswap_collision_free_position=350.0, + max_iswap_collision_free_position=600.0, +) + + +class STARChatterboxDriver(STARDriver): + """Chatterbox driver for STAR. Prints firmware commands instead of sending them over USB.""" + + def __init__( + self, + num_channels: int = 8, + machine_configuration: MachineConfiguration = _DEFAULT_MACHINE_CONF, + extended_configuration: ExtendedConfiguration = _DEFAULT_EXTENDED_CONF, + ): + super().__init__() + self._num_channels = num_channels + self._machine_configuration = machine_configuration + self._extended_configuration = extended_configuration + + @property + def num_channels(self) -> int: + return self._num_channels + + # -- lifecycle: skip USB, use canned config -------------------------------- + + async def setup(self): + # No USB — just set config and create backends. + self.id_ = 0 + self.machine_conf = self._machine_configuration + self.extended_conf = self._extended_configuration + + from .pip_backend import STARPIPBackend + + self.pip = STARPIPBackend(self) + + if self.extended_conf.left_x_drive.core_96_head_installed: + from .head96_backend import STARHead96Backend + + self.head96 = STARHead96Backend(self) + else: + self.head96 = None + + if self.extended_conf.left_x_drive.iswap_installed: + from .iswap import iSWAP + + self.iswap = iSWAP(driver=self) + self.iswap._version = "chatterbox" + self.iswap._parked = True + else: + self.iswap = None + + if self.machine_conf.auto_load_installed: + from .autoload import STARAutoload + + self.autoload = STARAutoload( + driver=self, + instrument_size_slots=self.extended_conf.instrument_size_slots, + ) + else: + self.autoload = None + + from .x_arm import STARXArm + + self.left_x_arm = STARXArm(driver=self, side="left") + if self.extended_conf.right_x_drive_large: + self.right_x_arm = STARXArm(driver=self, side="right") + else: + self.right_x_arm = None + + from .cover import STARCover + + self.cover = STARCover(driver=self) + + if (self.machine_conf.wash_station_1_installed or + self.machine_conf.wash_station_2_installed): + from .wash_station import STARWashStation + + self.wash_station = STARWashStation(driver=self) + else: + self.wash_station = None + + async def stop(self): + self.machine_conf = None + self.extended_conf = None + self.head96 = None + self.iswap = None + self.autoload = None + self.left_x_arm = None + self.right_x_arm = None + self.cover = None + self.wash_station = None + + # -- I/O: print instead of USB -------------------------------------------- + + async def send_command(self, module, command, auto_id=True, tip_pattern=None, + write_timeout=None, read_timeout=None, wait=True, + fmt=None, **kwargs): + cmd, _ = self._assemble_command( + module=module, command=command, auto_id=auto_id, + tip_pattern=tip_pattern, **kwargs, + ) + print(cmd) + return None + + async def send_raw_command(self, command, write_timeout=None, read_timeout=None, + wait=True): + print(command) + return None diff --git a/pylabrobot/hamilton/liquid_handlers/star/core.py b/pylabrobot/hamilton/liquid_handlers/star/core.py index a1a3156f8d6..035c07c9741 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/core.py +++ b/pylabrobot/hamilton/liquid_handlers/star/core.py @@ -4,7 +4,7 @@ from pylabrobot.arms.backend import GripperArmBackend from pylabrobot.arms.standard import GripperLocation from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.legacy.liquid_handling.backends.hamilton.base import HamiltonLiquidHandler +from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver from pylabrobot.resources import Coordinate @@ -15,8 +15,8 @@ class CoreGripper(GripperArmBackend): Tool management (pick up / return) is handled by the STAR backend. """ - def __init__(self, interface: HamiltonLiquidHandler): - self.interface = interface + def __init__(self, driver: STARDriver): + self.driver = driver # -- lifecycle -------------------------------------------------------------- @@ -65,7 +65,7 @@ async def pick_up_at_location( if not 0 <= backend_params.z_position_at_end <= 360.0: raise ValueError("z_position_at_end must be between 0 and 360.0") - await self.interface.send_command( + await self.driver.send_command( module="C0", command="ZP", xs=f"{abs(round(location.x * 10)):05}", @@ -117,7 +117,7 @@ async def drop_at_location( if not 0 <= backend_params.z_position_at_end <= 360.0: raise ValueError("z_position_at_end must be between 0 and 360.0") - await self.interface.send_command( + await self.driver.send_command( module="C0", command="ZR", xs=f"{abs(round(location.x * 10)):05}", @@ -160,7 +160,7 @@ async def move_to_location( if not 0 <= backend_params.minimum_traverse_height <= 360.0: raise ValueError("minimum_traverse_height must be between 0 and 360.0") - await self.interface.send_command( + await self.driver.send_command( module="C0", command="ZM", xs=f"{abs(round(location.x * 10)):05}", @@ -176,7 +176,7 @@ async def open_gripper( self, gripper_width: float, backend_params: Optional[BackendParams] = None ) -> None: """Open the CoRe gripper.""" - await self.interface.send_command(module="C0", command="ZO") + await self.driver.send_command(module="C0", command="ZO") async def close_gripper( self, gripper_width: float, backend_params: Optional[BackendParams] = None diff --git a/pylabrobot/hamilton/liquid_handlers/star/cover.py b/pylabrobot/hamilton/liquid_handlers/star/cover.py new file mode 100644 index 00000000000..0bdce3335ef --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/cover.py @@ -0,0 +1,65 @@ +"""STARCover: cover and port control for Hamilton STAR liquid handlers.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .driver import STARDriver + +logger = logging.getLogger(__name__) + + +class STARCover: + """Controls the cover and port outputs on a Hamilton STAR. + + This is a plain helper class (not a CapabilityBackend). It encapsulates the + firmware protocol for the cover control subsystem and delegates I/O to the driver. + """ + + def __init__(self, driver: "STARDriver"): + self._driver = driver + + async def lock(self): + """Lock cover (C0:CO).""" + return await self._driver.send_command(module="C0", command="CO") + + async def unlock(self): + """Unlock cover (C0:HO).""" + return await self._driver.send_command(module="C0", command="HO") + + async def disable(self): + """Disable cover control (C0:CD).""" + return await self._driver.send_command(module="C0", command="CD") + + async def enable(self): + """Enable cover control (C0:CE).""" + return await self._driver.send_command(module="C0", command="CE") + + async def set_output(self, output: int = 1): + """Set cover output (C0:OS). + + Args: + output: 1 = cover lock; 2 = reserve out; 3 = reserve out. + """ + assert 1 <= output <= 3, "output must be between 1 and 3" + return await self._driver.send_command(module="C0", command="OS", on=output) + + async def reset_output(self, output: int = 1): + """Reset output (C0:QS). + + Args: + output: 1 = cover lock; 2 = reserve out; 3 = reserve out. + """ + assert 1 <= output <= 3, "output must be between 1 and 3" + return await self._driver.send_command(module="C0", command="QS", on=output, fmt="#") + + async def is_open(self) -> bool: + """Request whether the cover is open (C0:QC). + + Returns: + True if the cover is open. + """ + resp = await self._driver.send_command(module="C0", command="QC", fmt="qc#") + return bool(resp["qc"]) diff --git a/pylabrobot/hamilton/liquid_handlers/star/driver.py b/pylabrobot/hamilton/liquid_handlers/star/driver.py new file mode 100644 index 00000000000..22469a53602 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/driver.py @@ -0,0 +1,991 @@ +"""STARDriver: inherits HamiltonLiquidHandler, adds STAR-specific config and error handling.""" + +import datetime +import enum +import re +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + +from pylabrobot.capabilities.liquid_handling.head96_backend import Head96Backend +from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend +from pylabrobot.hamilton.liquid_handlers.base import HamiltonLiquidHandler +from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import ( + STARFirmwareError, + parse_star_fw_string, + star_firmware_string_to_error, +) +from pylabrobot.resources.hamilton import TipPickupMethod, TipSize + + +# --------------------------------------------------------------------------- +# Configuration dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class DriveConfiguration: + """Configuration for an X drive (left or right).""" + + pip_installed: bool = False + iswap_installed: bool = False + core_96_head_installed: bool = False + nano_pipettor_installed: bool = False + dispensing_head_384_installed: bool = False + xl_channels_installed: bool = False + tube_gripper_installed: bool = False + imaging_channel_installed: bool = False + robotic_channel_installed: bool = False + + +@dataclass +class MachineConfiguration: + """Response from RM (Request Machine Configuration) command.""" + + pip_type_1000ul: bool = False + kb_iswap_installed: bool = False + main_front_cover_monitoring_installed: bool = False + auto_load_installed: bool = False + wash_station_1_installed: bool = False + wash_station_2_installed: bool = False + temp_controlled_carrier_1_installed: bool = False + temp_controlled_carrier_2_installed: bool = False + num_pip_channels: int = 0 + + +@dataclass +class ExtendedConfiguration: + """Response from QM (Request Extended Configuration) command.""" + + left_x_drive_large: bool = False + ka_core_96_head_installed: bool = False + right_x_drive_large: bool = False + pump_station_1_installed: bool = False + pump_station_2_installed: bool = False + wash_station_1_type_cr: bool = False + wash_station_2_type_cr: bool = False + left_cover_installed: bool = False + right_cover_installed: bool = False + additional_front_cover_monitoring_installed: bool = False + pump_station_3_installed: bool = False + multi_channel_nano_pipettor_installed: bool = False + dispensing_head_384_installed: bool = False + xl_channels_installed: bool = False + tube_gripper_installed: bool = False + waste_direction_left: bool = False + iswap_gripper_wide: bool = False + additional_channel_nano_pipettor_installed: bool = False + imaging_channel_installed: bool = False + robotic_channel_installed: bool = False + channel_order_ox_first: bool = False + x0_interface_ham_can: bool = False + park_heads_with_iswap_off: bool = False + configuration_data_3: int = 0 + instrument_size_slots: int = 54 + auto_load_size_slots: int = 54 + tip_waste_x_position: float = 1340.0 + left_x_drive: DriveConfiguration = field(default_factory=DriveConfiguration) + right_x_drive: DriveConfiguration = field(default_factory=DriveConfiguration) + min_iswap_collision_free_position: float = 350.0 + max_iswap_collision_free_position: float = 1140.0 + left_x_arm_width: float = 370.0 + right_x_arm_width: float = 370.0 + num_xl_channels: int = 0 + num_robotic_channels: int = 0 + min_raster_pitch_pip_channels: float = 9.0 + min_raster_pitch_xl_channels: float = 36.0 + min_raster_pitch_robotic_channels: float = 36.0 + pip_maximal_y_position: float = 606.5 + left_arm_min_y_position: float = 6.0 + right_arm_min_y_position: float = 6.0 + + +# --------------------------------------------------------------------------- +# STARDriver +# --------------------------------------------------------------------------- + + +class STARDriver(HamiltonLiquidHandler): + """Driver for Hamilton STAR liquid handlers. + + Inherits USB I/O, command assembly, and background reading from HamiltonLiquidHandler. + Adds STAR-specific firmware parsing, error handling, and machine configuration. + """ + + def __init__( + self, + device_address: Optional[int] = None, + serial_number: Optional[str] = None, + packet_read_timeout: int = 3, + read_timeout: int = 30, + write_timeout: int = 30, + ): + super().__init__( + id_product=0x8000, + device_address=device_address, + serial_number=serial_number, + packet_read_timeout=packet_read_timeout, + read_timeout=read_timeout, + write_timeout=write_timeout, + ) + + # Populated during setup(). + self.machine_conf: Optional[MachineConfiguration] = None + self.extended_conf: Optional[ExtendedConfiguration] = None + self.pip: PIPBackend # set in setup() + self.head96: Optional[Head96Backend] = None # set in setup() if installed + self.iswap: Optional["iSWAPBackend"] = None # set in setup() if installed + self.autoload: Optional["STARAutoload"] = None # set in setup() if installed + self.left_x_arm: Optional["STARXArm"] = None # set in setup() + self.right_x_arm: Optional["STARXArm"] = None # set in setup() + self.cover: Optional["STARCover"] = None # set in setup() + self.wash_station: Optional["STARWashStation"] = None # set in setup() + + # -- HamiltonLiquidHandler abstract methods -------------------------------- + + @property + def module_id_length(self) -> int: + return 2 + + @property + def num_channels(self) -> int: + if self.machine_conf is None: + raise RuntimeError("Driver not set up — call setup() first.") + return self.machine_conf.num_pip_channels + + def get_id_from_fw_response(self, resp: str) -> Optional[int]: + parsed = parse_star_fw_string(resp, "id####") + if "id" in parsed and parsed["id"] is not None: + return int(parsed["id"]) + return None + + def check_fw_string_error(self, resp: str) -> None: + module = resp[:2] + if module == "C0": + exp = r"er(?P[0-9]{2}/[0-9]{2})" + for mod in [ + "X0", "I0", "W1", "W2", "T1", "T2", "R0", + "P1", "P2", "P3", "P4", "P5", "P6", "P7", "P8", + "P9", "PA", "PB", "PC", "PD", "PE", "PF", "PG", + "H0", "HW", "HU", "HV", "N0", "D0", "NP", "M1", + ]: + exp += f" ?(?:{mod}(?P<{mod}>[0-9]{{2}}/[0-9]{{2}}))?" + errors = re.search(exp, resp) + else: + exp = f"er(?P<{module}>[0-9]{{2}})" + errors = re.search(exp, resp) + + if errors is None: + return + + errors_dict = {k: v for k, v in errors.groupdict().items() if v is not None} + errors_dict = {k: v for k, v in errors_dict.items() if v not in ("00", "00/00")} + + if len(errors_dict) > 0: + raise star_firmware_string_to_error(error_code_dict=errors_dict, raw_response=resp) + + def _parse_response(self, resp: str, fmt: Any) -> dict: + return parse_star_fw_string(resp, fmt) + + async def define_tip_needle( + self, + tip_type_table_index: int, + has_filter: bool, + tip_length: int, + maximum_tip_volume: int, + tip_size: TipSize, + pickup_method: TipPickupMethod, + ) -> None: + assert 0 <= tip_type_table_index <= 99 + assert 1 <= tip_length <= 1999 + assert 1 <= maximum_tip_volume <= 56000 + + await self.send_command( + module="C0", + command="TT", + tt=f"{tip_type_table_index:02}", + tf=has_filter, + tl=f"{tip_length:04}", + tv=f"{maximum_tip_volume:05}", + tg=tip_size.value, + tu=pickup_method.value, + ) + + # -- lifecycle ------------------------------------------------------------ + + async def setup(self): + await super().setup() + self.id_ = 0 + self.machine_conf = await self._request_machine_configuration() + self.extended_conf = await self._request_extended_configuration() + + # Create backends based on discovered config. + from .pip_backend import STARPIPBackend # deferred to avoid circular imports + + self.pip = STARPIPBackend(self) + + if self.extended_conf.left_x_drive.core_96_head_installed: + from .head96_backend import STARHead96Backend + + self.head96 = STARHead96Backend(self) + else: + self.head96 = None + + if self.extended_conf.left_x_drive.iswap_installed: + from .iswap import iSWAP + + self.iswap = iSWAP(driver=self) + else: + self.iswap = None + + if self.machine_conf.auto_load_installed: + from .autoload import STARAutoload + + self.autoload = STARAutoload( + driver=self, + instrument_size_slots=self.extended_conf.instrument_size_slots, + ) + else: + self.autoload = None + + from .x_arm import STARXArm + + self.left_x_arm = STARXArm(driver=self, side="left") + if self.extended_conf.right_x_drive_large: + self.right_x_arm = STARXArm(driver=self, side="right") + else: + self.right_x_arm = None + + # Cover is always present. + from .cover import STARCover + + self.cover = STARCover(driver=self) + + if (self.machine_conf.wash_station_1_installed or + self.machine_conf.wash_station_2_installed): + from .wash_station import STARWashStation + + self.wash_station = STARWashStation(driver=self) + else: + self.wash_station = None + + async def stop(self): + await super().stop() + self.machine_conf = None + self.extended_conf = None + self.head96 = None + self.iswap = None + self.autoload = None + self.left_x_arm = None + self.right_x_arm = None + self.cover = None + self.wash_station = None + + # -- liquid level probing --------------------------------------------------- + + async def probe_liquid_heights(self, containers, use_channels, resource_offsets=None, + move_to_z_safety_after=True, **kwargs): + """Probe liquid heights using cLLD. Override in subclasses with real implementation.""" + raise NotImplementedError( + "probe_liquid_heights is not implemented on STARDriver. " + "Use STARBackend (legacy) or implement probing on your driver subclass." + ) + + # -- core gripper tool management ------------------------------------------ + + async def pick_up_core_gripper_tools( + self, + x_position: float, + back_channel_y: float, + front_channel_y: float, + back_channel: int, + front_channel: int, + begin_z: float = 235.0, + end_z: float = 225.0, + traversal_height: float = 280.0, + ): + """Pick up CoRe gripper tools from the mount (C0ZT).""" + await self.send_command( + module="C0", + command="ZT", + xs=f"{round(x_position * 10):05}", + xd="0", + ya=f"{round(back_channel_y * 10):04}", + yb=f"{round(front_channel_y * 10):04}", + pa=f"{back_channel + 1:02}", + pb=f"{front_channel + 1:02}", + tp=f"{round(begin_z * 10):04}", + tz=f"{round(end_z * 10):04}", + th=round(traversal_height * 10), + tt="14", + ) + + async def return_core_gripper_tools( + self, + x_position: float, + back_channel_y: float, + front_channel_y: float, + begin_z: float = 215.0, + end_z: float = 205.0, + traversal_height: float = 280.0, + ): + """Return CoRe gripper tools to the mount (C0ZS).""" + await self.send_command( + module="C0", + command="ZS", + xs=f"{round(x_position * 10):05}", + xd="0", + ya=f"{round(back_channel_y * 10):04}", + yb=f"{round(front_channel_y * 10):04}", + tp=f"{round(begin_z * 10):04}", + tz=f"{round(end_z * 10):04}", + th=round(traversal_height * 10), + te=round(traversal_height * 10), + ) + + # -- machine configuration ------------------------------------------------ + + async def _request_machine_configuration(self) -> MachineConfiguration: + resp = await self.send_command(module="C0", command="RM", fmt="kb**kp##") + kb = resp["kb"] + return MachineConfiguration( + pip_type_1000ul=bool(kb & (1 << 0)), + kb_iswap_installed=bool(kb & (1 << 1)), + main_front_cover_monitoring_installed=bool(kb & (1 << 2)), + auto_load_installed=bool(kb & (1 << 3)), + wash_station_1_installed=bool(kb & (1 << 4)), + wash_station_2_installed=bool(kb & (1 << 5)), + temp_controlled_carrier_1_installed=bool(kb & (1 << 6)), + temp_controlled_carrier_2_installed=bool(kb & (1 << 7)), + num_pip_channels=resp["kp"], + ) + + async def _request_extended_configuration(self) -> ExtendedConfiguration: + resp = await self.send_command( + module="C0", + command="QM", + fmt="ka******ke********xt##xa##xw#####xl**xn**xr**xo**xm#####xx#####xu####xv####kc#kr#" + + "ys###kl###km###ym####yu####yx####", + ) + + def _parse_drive(byte1: int, byte2: int) -> DriveConfiguration: + return DriveConfiguration( + pip_installed=bool(byte1 & (1 << 0)), + iswap_installed=bool(byte1 & (1 << 1)), + core_96_head_installed=bool(byte1 & (1 << 2)), + nano_pipettor_installed=bool(byte1 & (1 << 3)), + dispensing_head_384_installed=bool(byte1 & (1 << 4)), + xl_channels_installed=bool(byte1 & (1 << 5)), + tube_gripper_installed=bool(byte1 & (1 << 6)), + imaging_channel_installed=bool(byte1 & (1 << 7)), + robotic_channel_installed=bool(byte2 & (1 << 0)), + ) + + ka = resp["ka"] + return ExtendedConfiguration( + left_x_drive_large=bool(ka & (1 << 0)), + ka_core_96_head_installed=bool(ka & (1 << 1)), + right_x_drive_large=bool(ka & (1 << 2)), + pump_station_1_installed=bool(ka & (1 << 3)), + pump_station_2_installed=bool(ka & (1 << 4)), + wash_station_1_type_cr=bool(ka & (1 << 5)), + wash_station_2_type_cr=bool(ka & (1 << 6)), + left_cover_installed=bool(ka & (1 << 7)), + right_cover_installed=bool(ka & (1 << 8)), + additional_front_cover_monitoring_installed=bool(ka & (1 << 9)), + pump_station_3_installed=bool(ka & (1 << 10)), + multi_channel_nano_pipettor_installed=bool(ka & (1 << 11)), + dispensing_head_384_installed=bool(ka & (1 << 12)), + xl_channels_installed=bool(ka & (1 << 13)), + tube_gripper_installed=bool(ka & (1 << 14)), + waste_direction_left=bool(ka & (1 << 15)), + iswap_gripper_wide=bool(ka & (1 << 16)), + additional_channel_nano_pipettor_installed=bool(ka & (1 << 17)), + imaging_channel_installed=bool(ka & (1 << 18)), + robotic_channel_installed=bool(ka & (1 << 19)), + channel_order_ox_first=bool(ka & (1 << 20)), + x0_interface_ham_can=bool(ka & (1 << 21)), + park_heads_with_iswap_off=bool(ka & (1 << 22)), + configuration_data_3=resp["ke"], + instrument_size_slots=resp["xt"], + auto_load_size_slots=resp["xa"], + tip_waste_x_position=resp["xw"] / 10, + left_x_drive=_parse_drive(resp["xl"], resp["xn"]), + right_x_drive=_parse_drive(resp["xr"], resp["xo"]), + min_iswap_collision_free_position=resp["xm"] / 10, + max_iswap_collision_free_position=resp["xx"] / 10, + left_x_arm_width=resp["xu"] / 10, + right_x_arm_width=resp["xv"] / 10, + num_xl_channels=resp["kc"], + num_robotic_channels=resp["kr"], + min_raster_pitch_pip_channels=resp["ys"] / 10, + min_raster_pitch_xl_channels=resp["kl"] / 10, + min_raster_pitch_robotic_channels=resp["km"] / 10, + pip_maximal_y_position=resp["ym"] / 10, + left_arm_min_y_position=resp["yu"] / 10, + right_arm_min_y_position=resp["yx"] / 10, + ) + + # -- generic instrument operations -- + + class BoardType(enum.Enum): + C167CR_SINGLE_PROCESSOR_BOARD = 0 + C167CR_DUAL_PROCESSOR_BOARD = 1 + LPC2468_XE167_DUAL_PROCESSOR_BOARD = 2 + LPC2468_SINGLE_PROCESSOR_BOARD = 5 + UNKNOWN = -1 + + # --- Firmware queries --- + + async def request_error_code(self): + """Request error code (C0:RE). + + Retrieves the last saved error messages. The error buffer is automatically voided + when a new command is started. All configured nodes are displayed. + """ + + return await self.send_command(module="C0", command="RE") + + async def request_firmware_version(self): + """Request firmware version (C0:RF).""" + + return await self.send_command(module="C0", command="RF") + + async def request_parameter_value(self): + """Request parameter value (C0:RA).""" + + return await self.send_command(module="C0", command="RA") + + async def request_master_status(self): + """Request master status (C0:RQ).""" + + return await self.send_command(module="C0", command="RQ") + + async def request_eeprom_data_correctness(self): + """Request EEPROM data correctness (C0:QV).""" + + return await self.send_command(module="C0", command="QV") + + # --- Hardware config queries --- + + async def request_electronic_board_type(self): + """Request electronic board type (C0:QB). + + Returns: + The board type. + """ + + resp = await self.send_command(module="C0", command="QB", fmt="qb#") + try: + return STARDriver.BoardType(resp["qb"]) + except ValueError: + return STARDriver.BoardType.UNKNOWN + + async def request_supply_voltage(self): + """Request supply voltage (C0:MU). + + Request supply voltage (for LDPB only). + """ + + return await self.send_command(module="C0", command="MU") + + async def request_number_of_presence_sensors_installed(self): + """Request number of presence sensors installed (C0:SR). + + Returns: + Number of sensors installed (1...103). + """ + + resp = await self.send_command(module="C0", command="SR", fmt="sr###") + return resp["sr"] + + # --- Init status + diagnostics --- + + async def request_instrument_initialization_status(self) -> bool: + """Request instrument initialization status (C0:QW).""" + + resp = await self.send_command(module="C0", command="QW", fmt="qw#") + return resp is not None and resp["qw"] == 1 + + async def request_name_of_last_faulty_parameter(self): + """Request name of last faulty parameter (C0:VP). + + Returns: + Name of last parameter with syntax error, optionally followed by the received value, + minimal permitted value, and maximal permitted value. + """ + + return await self.send_command(module="C0", command="VP", fmt="vp&&") + + # --- Runtime control --- + + async def set_single_step_mode(self, single_step_mode: bool = False): + """Set single step mode (C0:AM). + + Args: + single_step_mode: Single Step Mode. Default False. + """ + + return await self.send_command( + module="C0", + command="AM", + am=single_step_mode, + ) + + async def trigger_next_step(self): + """Trigger next step in single step mode (C0:NS).""" + + return await self.send_command(module="C0", command="NS") + + async def halt(self): + """Halt (C0:HD). + + Intermediate sequences not yet carried out and the commands in the command stack are + discarded. The sequence already in process is completed. + """ + + return await self.send_command(module="C0", command="HD") + + async def set_not_stop(self, non_stop): + """Set not stop mode (C0:AB/AW). + + Args: + non_stop: True if non stop mode should be turned on after command is sent. + """ + + if non_stop: + return await self.send_command(module="C0", command="AB") + else: + return await self.send_command(module="C0", command="AW") + + async def save_all_cycle_counters(self): + """Save all cycle counters of the instrument (C0:AZ).""" + + return await self.send_command(module="C0", command="AZ") + + # --- X-drive queries --- + + async def request_maximal_ranges_of_x_drives(self): + """Request maximal ranges of X drives (C0:RU).""" + + return await self.send_command(module="C0", command="RU") + + async def request_present_wrap_size_of_installed_arms(self): + """Request present wrap size of installed arms (C0:UA).""" + + return await self.send_command(module="C0", command="UA") + + # -- EEPROM operations -- + + async def store_installation_data( + self, + date: datetime.datetime = datetime.datetime.now(), + serial_number: str = "0000", + ): + """Store installation data (C0:SI). + + Args: + date: installation date. + serial_number: 4-character serial number string. + """ + + assert len(serial_number) == 4, "serial number must be 4 chars long" + + return await self.send_command(module="C0", command="SI", si=date, sn=serial_number) + + async def store_verification_data( + self, + verification_subject: int = 0, + date: datetime.datetime = datetime.datetime.now(), + verification_status: bool = False, + ): + """Store verification data (C0:AV). + + Args: + verification_subject: verification subject. Default 0. Must be between 0 and 24. + date: verification date. + verification_status: verification status. + """ + + assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" + + return await self.send_command( + module="C0", + command="AV", + vo=verification_subject, + vd=date, + vs=verification_status, + ) + + async def additional_time_stamp(self): + """Additional time stamp (C0:AT).""" + + return await self.send_command(module="C0", command="AT") + + async def save_download_date(self, date: datetime.datetime = datetime.datetime.now()): + """Save Download date (C0:AO). + + Args: + date: download date. Default now. + """ + + return await self.send_command( + module="C0", + command="AO", + ao=date, + ) + + async def save_technical_status_of_assemblies(self, processor_board: str, power_supply: str): + """Save technical status of assemblies (C0:BT). + + Args: + processor_board: Processor board. Art.Nr./Rev./Ser.No. (000000/00/0000) + power_supply: Power supply. Art.Nr./Rev./Ser.No. (000000/00/0000) + """ + + return await self.send_command( + module="C0", + command="BT", + qt=processor_board + " " + power_supply, + ) + + async def set_x_offset_x_axis_iswap(self, x_offset: int): + """Set X-offset X-axis <-> iSWAP (C0:AG). + + Args: + x_offset: X-offset [0.1mm] + """ + + return await self.send_command(module="C0", command="AG", x_offset=x_offset) + + async def set_x_offset_x_axis_core_96_head(self, x_offset: int): + """Set X-offset X-axis <-> CoRe 96 head (C0:AF). + + Args: + x_offset: X-offset [0.1mm] + """ + + return await self.send_command(module="C0", command="AF", x_offset=x_offset) + + async def set_x_offset_x_axis_core_nano_pipettor_head(self, x_offset: int): + """Set X-offset X-axis <-> CoRe 96 head (C0:AF). + + Args: + x_offset: X-offset [0.1mm] + """ + + return await self.send_command(module="C0", command="AF", x_offset=x_offset) + + async def save_pip_channel_validation_status(self, validation_status: bool = False): + """Save PIP channel validation status (C0:AJ). + + Args: + validation_status: PIP channel validation status. Default False. + """ + + return await self.send_command( + module="C0", + command="AJ", + tq=validation_status, + ) + + async def save_xl_channel_validation_status(self, validation_status: bool = False): + """Save XL channel validation status (C0:AE). + + Args: + validation_status: XL channel validation status. Default False. + """ + + return await self.send_command( + module="C0", + command="AE", + tx=validation_status, + ) + + async def configure_node_names(self): + """Configure node names (C0:AJ).""" + + return await self.send_command(module="C0", command="AJ") + + async def set_deck_data(self, data_index: int = 0, data_stream: str = "0"): + """Set deck data (C0:DD). + + Args: + data_index: data index. Must be between 0 and 9. Default 0. + data_stream: data stream (12 characters). Default . + """ + + assert 0 <= data_index <= 9, "data_index must be between 0 and 9" + assert len(data_stream) == 12, "data_stream must be 12 chars" + + return await self.send_command( + module="C0", + command="DD", + vi=data_index, + vj=data_stream, + ) + + async def request_technical_status_of_assemblies(self): + """Request Technical status of assemblies (C0:QT).""" + + # TODO: parse res + return await self.send_command(module="C0", command="QT") + + async def request_installation_data(self): + """Request installation data (C0:RI).""" + + # TODO: parse res + return await self.send_command(module="C0", command="RI") + + async def request_device_serial_number(self) -> str: + """Request device serial number (C0:RI).""" + return (await self.send_command("C0", "RI", fmt="si####sn&&&&sn&&&&"))["sn"] # type: ignore + + async def request_download_date(self): + """Request download date (C0:RO).""" + + # TODO: parse res + return await self.send_command(module="C0", command="RO") + + async def request_verification_data(self, verification_subject: int = 0): + """Request download date (C0:RO). + + Args: + verification_subject: verification subject. Must be between 0 and 24. Default 0. + """ + + assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" + + # TODO: parse results. + return await self.send_command(module="C0", command="RO", vo=verification_subject) + + async def request_additional_timestamp_data(self): + """Request additional timestamp data (C0:RS).""" + + # TODO: parse res + return await self.send_command(module="C0", command="RS") + + async def request_pip_channel_validation_status(self): + """Request PIP channel validation status (C0:RJ).""" + + # TODO: parse res + return await self.send_command(module="C0", command="RJ") + + async def request_xl_channel_validation_status(self): + """Request XL channel validation status (C0:UJ).""" + + # TODO: parse res + return await self.send_command(module="C0", command="UJ") + + async def request_node_names(self): + """Request node names (C0:RK).""" + + # TODO: parse res + return await self.send_command(module="C0", command="RK") + + async def request_deck_data(self): + """Request deck data (C0:VD).""" + + # TODO: parse res + return await self.send_command(module="C0", command="VD") + + # -- area reservation and configuration -- + + async def occupy_and_provide_area_for_external_access( + self, + taken_area_identification_number: int = 0, + taken_area_left_margin: int = 0, + taken_area_left_margin_direction: int = 0, + taken_area_size: int = 0, + arm_preposition_mode_related_to_taken_areas: int = 0, + ): + """Occupy and provide area for external access + + Args: + taken_area_identification_number: taken area identification number. Must be between 0 and + 9999. Default 0. + taken_area_left_margin: taken area left margin. Must be between 0 and 99. Default 0. + taken_area_left_margin_direction: taken area left margin direction. 1 = negative. Must be + between 0 and 1. Default 0. + taken_area_size: taken area size. Must be between 0 and 50000. Default 0. + arm_preposition_mode_related_to_taken_areas: 0) left arm to left & right arm to right. + 1) all arms left. 2) all arms right. + """ + + assert 0 <= taken_area_identification_number <= 9999, ( + "taken_area_identification_number must be between 0 and 9999" + ) + assert 0 <= taken_area_left_margin <= 99, "taken_area_left_margin must be between 0 and 99" + assert 0 <= taken_area_left_margin_direction <= 1, ( + "taken_area_left_margin_direction must be between 0 and 1" + ) + assert 0 <= taken_area_size <= 50000, "taken_area_size must be between 0 and 50000" + assert 0 <= arm_preposition_mode_related_to_taken_areas <= 2, ( + "arm_preposition_mode_related_to_taken_areas must be between 0 and 2" + ) + + return await self.send_command( + module="C0", + command="BA", + aq=taken_area_identification_number, + al=taken_area_left_margin, + ad=taken_area_left_margin_direction, + ar=taken_area_size, + ap=arm_preposition_mode_related_to_taken_areas, + ) + + async def release_occupied_area(self, taken_area_identification_number: int = 0): + """Release occupied area + + Args: + taken_area_identification_number: taken area identification number. + Must be between 0 and 99. Default 0. + """ + + assert 0 <= taken_area_identification_number <= 99, ( + "taken_area_identification_number must be between 0 and 99" + ) + + return await self.send_command( + module="C0", + command="BB", + aq=taken_area_identification_number, + ) + + async def release_all_occupied_areas(self): + """Release all occupied areas""" + + return await self.send_command(module="C0", command="BC") + + async def set_instrument_configuration( + self, + configuration_data_1: Optional[str] = None, # TODO: configuration byte + configuration_data_2: Optional[str] = None, # TODO: configuration byte + configuration_data_3: Optional[str] = None, # TODO: configuration byte + instrument_size_in_slots_x_range: int = 54, + auto_load_size_in_slots: int = 54, + tip_waste_x_position: int = 13400, + right_x_drive_configuration_byte_1: int = 0, + right_x_drive_configuration_byte_2: int = 0, + minimal_iswap_collision_free_position: int = 3500, + maximal_iswap_collision_free_position: int = 11400, + left_x_arm_width: int = 3700, + right_x_arm_width: int = 3700, + num_pip_channels: int = 0, + num_xl_channels: int = 0, + num_robotic_channels: int = 0, + minimal_raster_pitch_of_pip_channels: int = 90, + minimal_raster_pitch_of_xl_channels: int = 360, + minimal_raster_pitch_of_robotic_channels: int = 360, + pip_maximal_y_position: int = 6065, + left_arm_minimal_y_position: int = 60, + right_arm_minimal_y_position: int = 60, + ): + """Set instrument configuration + + Args: + configuration_data_1: configuration data 1. + configuration_data_2: configuration data 2. + configuration_data_3: configuration data 3. + instrument_size_in_slots_x_range: instrument size in slots (X range). + Must be between 10 and 99. Default 54. + auto_load_size_in_slots: auto load size in slots. Must be between 10 + and 54. Default 54. + tip_waste_x_position: tip waste X-position. Must be between 1000 and + 25000. Default 13400. + right_x_drive_configuration_byte_1: right X drive configuration byte 1 (see + xl parameter bits). Must be between 0 and 1. Default 0. # TODO: this. + right_x_drive_configuration_byte_2: right X drive configuration byte 2 (see + xn parameter bits). Must be between 0 and 1. Default 0. # TODO: this. + minimal_iswap_collision_free_position: minimal iSWAP collision free position for + direct X access. For explanation of calculation see Fig. 4. Must be between 0 and 30000. + Default 3500. + maximal_iswap_collision_free_position: maximal iSWAP collision free position for + direct X access. For explanation of calculation see Fig. 4. Must be between 0 and 30000. + Default 11400 + left_x_arm_width: width of left X arm [0.1 mm]. Must be between 0 and 9999. Default 3700. + right_x_arm_width: width of right X arm [0.1 mm]. Must be between 0 and 9999. Default 3700. + num_pip_channels: number of PIP channels. Must be between 0 and 16. Default 0. + num_xl_channels: number of XL channels. Must be between 0 and 8. Default 0. + num_robotic_channels: number of Robotic channels. Must be between 0 and 8. Default 0. + minimal_raster_pitch_of_pip_channels: minimal raster pitch of PIP channels [0.1 mm]. Must + be between 0 and 999. Default 90. + minimal_raster_pitch_of_xl_channels: minimal raster pitch of XL channels [0.1 mm]. Must be + between 0 and 999. Default 360. + minimal_raster_pitch_of_robotic_channels: minimal raster pitch of Robotic channels [0.1 mm]. + Must be between 0 and 999. Default 360. + pip_maximal_y_position: PIP maximal Y position [0.1 mm]. Must be between 0 and 9999. + Default 6065. + left_arm_minimal_y_position: left arm minimal Y position [0.1 mm]. Must be between 0 and 9999. + Default 60. + right_arm_minimal_y_position: right arm minimal Y position [0.1 mm]. Must be between 0 + and 9999. Default 60. + """ + + assert 10 <= instrument_size_in_slots_x_range <= 99, ( + "instrument_size_in_slots_x_range must be between 10 and 99" + ) + assert 1 <= auto_load_size_in_slots <= 54, "auto_load_size_in_slots must be between 1 and 54" + assert 1000 <= tip_waste_x_position <= 25000, "tip_waste_x_position must be between 1 and 25000" + assert 0 <= right_x_drive_configuration_byte_1 <= 1, ( + "right_x_drive_configuration_byte_1 must be between 0 and 1" + ) + assert 0 <= right_x_drive_configuration_byte_2 <= 1, ( + "right_x_drive_configuration_byte_2 must be between 0 and must1" + ) + assert 0 <= minimal_iswap_collision_free_position <= 30000, ( + "minimal_iswap_collision_free_position must be between 0 and 30000" + ) + assert 0 <= maximal_iswap_collision_free_position <= 30000, ( + "maximal_iswap_collision_free_position must be between 0 and 30000" + ) + assert 0 <= left_x_arm_width <= 9999, "left_x_arm_width must be between 0 and 9999" + assert 0 <= right_x_arm_width <= 9999, "right_x_arm_width must be between 0 and 9999" + assert 0 <= num_pip_channels <= 16, "num_pip_channels must be between 0 and 16" + assert 0 <= num_xl_channels <= 8, "num_xl_channels must be between 0 and 8" + assert 0 <= num_robotic_channels <= 8, "num_robotic_channels must be between 0 and 8" + assert 0 <= minimal_raster_pitch_of_pip_channels <= 999, ( + "minimal_raster_pitch_of_pip_channels must be between 0 and 999" + ) + assert 0 <= minimal_raster_pitch_of_xl_channels <= 999, ( + "minimal_raster_pitch_of_xl_channels must be between 0 and 999" + ) + assert 0 <= minimal_raster_pitch_of_robotic_channels <= 999, ( + "minimal_raster_pitch_of_robotic_channels must be between 0 and 999" + ) + assert 0 <= pip_maximal_y_position <= 9999, "pip_maximal_y_position must be between 0 and 9999" + assert 0 <= left_arm_minimal_y_position <= 9999, ( + "left_arm_minimal_y_position must be between 0 and 9999" + ) + assert 0 <= right_arm_minimal_y_position <= 9999, ( + "right_arm_minimal_y_position must be between 0 and 9999" + ) + + return await self.send_command( + module="C0", + command="AK", + kb=configuration_data_1, + ka=configuration_data_2, + ke=configuration_data_3, + xt=instrument_size_in_slots_x_range, + xa=auto_load_size_in_slots, + xw=tip_waste_x_position, + xr=right_x_drive_configuration_byte_1, + xo=right_x_drive_configuration_byte_2, + xm=minimal_iswap_collision_free_position, + xx=maximal_iswap_collision_free_position, + xu=left_x_arm_width, + xv=right_x_arm_width, + kp=num_pip_channels, + kc=num_xl_channels, + kr=num_robotic_channels, + ys=minimal_raster_pitch_of_pip_channels, + kl=minimal_raster_pitch_of_xl_channels, + km=minimal_raster_pitch_of_robotic_channels, + ym=pip_maximal_y_position, + yu=left_arm_minimal_y_position, + yx=right_arm_minimal_y_position, + ) + + async def pre_initialize_instrument(self): + """Pre-initialize instrument""" + return await self.send_command(module="C0", command="VI", read_timeout=300) diff --git a/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py b/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py new file mode 100644 index 00000000000..6c62fd868be --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py @@ -0,0 +1,468 @@ +"""STAR Head96 backend: translates Head96 operations into STAR firmware commands.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Literal, Optional, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.head96_backend import Head96Backend +from pylabrobot.capabilities.liquid_handling.standard import ( + DropTipRack, + MultiHeadAspirationContainer, + MultiHeadAspirationPlate, + MultiHeadDispenseContainer, + MultiHeadDispensePlate, + PickupTipRack, +) +from pylabrobot.resources import Coordinate +from pylabrobot.resources.hamilton import HamiltonTip, TipSize + +if TYPE_CHECKING: + from .driver import STARDriver + + +def _dispensing_mode_for_op(empty: bool, jet: bool, blow_out: bool) -> int: + """Compute firmware dispensing mode from boolean flags. + + Firmware modes: + 0 = Partial volume in jet mode + 1 = Blow out in jet mode (labelled "empty" in VENUS) + 2 = Partial volume at surface + 3 = Blow out at surface (labelled "empty" in VENUS) + 4 = Empty tip at fix position + """ + if empty: + return 4 + if jet: + return 1 if blow_out else 0 + return 3 if blow_out else 2 + + +def _channel_pattern_to_hex(pattern: List[bool]) -> str: + """Convert a list of 96 booleans to the hex string expected by firmware.""" + assert len(pattern) == 96, "channel_pattern must be a list of 96 boolean values" + channel_pattern_bin_str = reversed(["1" if x else "0" for x in pattern]) + return hex(int("".join(channel_pattern_bin_str), 2)).upper()[2:] + + +class STARHead96Backend(Head96Backend): + """Translates Head96 operations into STAR firmware commands via the driver.""" + + # Default traversal height [mm] matching the legacy STARBackend default. + _traversal_height: float = 245.0 + + def __init__(self, driver: STARDriver): + self._driver = driver + + # --------------------------------------------------------------------------- + # Pick up tips + # --------------------------------------------------------------------------- + + @dataclass + class PickUpTips96Params(BackendParams): + """STAR-specific parameters for 96-head tip pickup.""" + + tip_pickup_method: Literal["from_rack", "from_waste", "full_blowout"] = "from_rack" + minimum_height_command_end: Optional[float] = None + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + alignment_tipspot_identifier: str = "A1" + + async def pick_up_tips96( + self, pickup: PickupTipRack, backend_params: Optional[BackendParams] = None + ): + """Pick up tips using the 96 head. + + Firmware command: C0 EP + """ + if not isinstance(backend_params, STARHead96Backend.PickUpTips96Params): + backend_params = STARHead96Backend.PickUpTips96Params() + + tip_pickup_method = backend_params.tip_pickup_method + if tip_pickup_method not in {"from_rack", "from_waste", "full_blowout"}: + raise ValueError(f"Invalid tip_pickup_method: '{tip_pickup_method}'.") + + prototypical_tip = next((tip for tip in pickup.tips if tip is not None), None) + if prototypical_tip is None: + raise ValueError("No tips found in the tip rack.") + if not isinstance(prototypical_tip, HamiltonTip): + raise TypeError("Tip type must be HamiltonTip.") + + ttti = await self._driver.get_or_assign_tip_type_index(prototypical_tip) + + tip_length = prototypical_tip.total_tip_length + fitting_depth = prototypical_tip.fitting_depth + tip_engage_height_from_tipspot = tip_length - fitting_depth + + # Adjust tip engage height based on tip size + if prototypical_tip.tip_size == TipSize.LOW_VOLUME: + tip_engage_height_from_tipspot += 2 + elif prototypical_tip.tip_size != TipSize.STANDARD_VOLUME: + tip_engage_height_from_tipspot -= 2 + + # Compute pickup position using absolute coordinates (deck is at origin) + alignment_tipspot = pickup.resource.get_item(backend_params.alignment_tipspot_identifier) + tip_spot_z = alignment_tipspot.get_absolute_location().z + pickup.offset.z + z_pickup_position = tip_spot_z + tip_engage_height_from_tipspot + + pickup_position = ( + alignment_tipspot.get_absolute_location() + alignment_tipspot.center() + pickup.offset + ) + pickup_position.z = round(z_pickup_position, 2) + + traversal = self._traversal_height + + if tip_pickup_method == "from_rack": + # Move the dispensing drive down before pickup. + # The STAR will not automatically move the dispensing drive down if it is still up. + # See https://github.com/PyLabRobot/pylabrobot/pull/835 + # + # Pre-computed increment values (uL / 0.019340933): + # position=218.19uL -> 11281, speed=261.1uL/s -> 13500, + # stop_speed=0 -> 0, acceleration=17406.84uL/s^2 -> 900000 + await self._driver.send_command( + module="H0", + command="DQ", + dq="11281", + dv="13500", + du="00000", + dr="900000", + dw="15", + ) + + await self._driver.send_command( + module="C0", + command="EP", + xs=f"{abs(round(pickup_position.x * 10)):05}", + xd=0 if pickup_position.x >= 0 else 1, + yh=f"{round(pickup_position.y * 10):04}", + tt=f"{ttti:02}", + wu={"from_rack": 0, "from_waste": 1, "full_blowout": 2}[tip_pickup_method], + za=f"{round(pickup_position.z * 10):04}", + zh=f"{round((backend_params.minimum_traverse_height_at_beginning_of_a_command or traversal) * 10):04}", + ze=f"{round((backend_params.minimum_height_command_end or traversal) * 10):04}", + ) + + # --------------------------------------------------------------------------- + # Drop tips + # --------------------------------------------------------------------------- + + @dataclass + class DropTips96Params(BackendParams): + """STAR-specific parameters for 96-head tip drop.""" + + minimum_height_command_end: Optional[float] = None + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + alignment_tipspot_identifier: str = "A1" + + async def drop_tips96( + self, drop: DropTipRack, backend_params: Optional[BackendParams] = None + ): + """Drop tips from the 96 head. + + Firmware command: C0 ER + """ + if not isinstance(backend_params, STARHead96Backend.DropTips96Params): + backend_params = STARHead96Backend.DropTips96Params() + + from pylabrobot.resources import TipRack + + if isinstance(drop.resource, TipRack): + tip_spot_a1 = drop.resource.get_item(backend_params.alignment_tipspot_identifier) + position = tip_spot_a1.get_absolute_location() + tip_spot_a1.center() + drop.offset + tip_rack = tip_spot_a1.parent + assert tip_rack is not None + position.z = tip_rack.get_absolute_location().z + 1.45 + else: + # Drop into trash or other resource: center the head in the resource. + position = self._position_96_head_in_resource(drop.resource) + drop.offset + + traversal = self._traversal_height + + await self._driver.send_command( + module="C0", + command="ER", + xs=f"{abs(round(position.x * 10)):05}", + xd=0 if position.x >= 0 else 1, + yh=f"{round(position.y * 10):04}", + za=f"{round(position.z * 10):04}", + zh=f"{round((backend_params.minimum_traverse_height_at_beginning_of_a_command or traversal) * 10):04}", + ze=f"{round((backend_params.minimum_height_command_end or traversal) * 10):04}", + ) + + # --------------------------------------------------------------------------- + # Aspirate + # --------------------------------------------------------------------------- + + @dataclass + class Aspirate96Params(BackendParams): + """STAR-specific parameters for 96-head aspiration.""" + + use_lld: bool = False + aspiration_type: int = 0 + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + min_z_endpos: Optional[float] = None + lld_search_height: float = 199.9 + minimum_height: Optional[float] = None + second_section_height: float = 3.2 + second_section_ratio: float = 618.0 + immersion_depth: float = 0 + surface_following_distance: float = 0 + transport_air_volume: float = 5.0 + pre_wetting_volume: float = 5.0 + gamma_lld_sensitivity: int = 1 + swap_speed: float = 2.0 + settling_time: float = 1.0 + mix_position_from_liquid_surface: float = 0 + mix_surface_following_distance: float = 0 + limit_curve_index: int = 0 + pull_out_distance_transport_air: float = 10 + tadm_algorithm: bool = False + recording_mode: int = 0 + + async def aspirate96( + self, + aspiration: Union[MultiHeadAspirationPlate, MultiHeadAspirationContainer], + backend_params: Optional[BackendParams] = None, + ): + """Aspirate using the Core96 head. + + Firmware command: C0 EA + """ + if not isinstance(backend_params, STARHead96Backend.Aspirate96Params): + backend_params = STARHead96Backend.Aspirate96Params() + + # Compute position + if isinstance(aspiration, MultiHeadAspirationPlate): + plate = aspiration.wells[0].parent + assert plate is not None, "MultiHeadAspirationPlate well parent must not be None" + rot = plate.get_absolute_rotation() + if rot.x % 360 != 0 or rot.y % 360 != 0: + raise ValueError("Plate rotation around x or y is not supported for 96 head operations") + if rot.z % 360 == 180: + ref_well = aspiration.wells[-1] + elif rot.z % 360 == 0: + ref_well = aspiration.wells[0] + else: + raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") + + position = ( + ref_well.get_absolute_location() + + ref_well.center() + + Coordinate(z=ref_well.material_z_thickness) + + aspiration.offset + ) + else: + # Container (trough): center the head + x_width = (12 - 1) * 9 # 12 tips in a row, 9 mm between them + y_width = (8 - 1) * 9 # 8 tips in a column, 9 mm between them + x_position = (aspiration.container.get_absolute_size_x() - x_width) / 2 + y_position = (aspiration.container.get_absolute_size_y() - y_width) / 2 + y_width + position = ( + aspiration.container.get_absolute_location(z="cavity_bottom") + + Coordinate(x=x_position, y=y_position) + + aspiration.offset + ) + + tip = next(tip for tip in aspiration.tips if tip is not None) + + liquid_height = position.z + (aspiration.liquid_height or 0) + + volume = aspiration.volume + flow_rate = aspiration.flow_rate or 250 + blow_out_air_volume = aspiration.blow_out_air_volume or 0 + + traversal = self._traversal_height + + immersion_depth = backend_params.immersion_depth + immersion_depth_direction = 0 if immersion_depth >= 0 else 1 + + await self._driver.send_command( + module="C0", + command="EA", + aa=backend_params.aspiration_type, + xs=f"{abs(round(position.x * 10)):05}", + xd=0 if position.x >= 0 else 1, + yh=f"{round(position.y * 10):04}", + zh=f"{round((backend_params.minimum_traverse_height_at_beginning_of_a_command or traversal) * 10):04}", + ze=f"{round((backend_params.min_z_endpos or traversal) * 10):04}", + lz=f"{round(backend_params.lld_search_height * 10):04}", + zt=f"{round(liquid_height * 10):04}", + pp=f"{round(backend_params.pull_out_distance_transport_air * 10):04}", + zm=f"{round((backend_params.minimum_height or position.z) * 10):04}", + zv=f"{round(backend_params.second_section_height * 10):04}", + zq=f"{round(backend_params.second_section_ratio * 10):05}", + iw=f"{round(abs(immersion_depth) * 10):03}", + ix=immersion_depth_direction, + fh=f"{round(backend_params.surface_following_distance * 10):03}", + af=f"{round(volume * 10):05}", + ag=f"{round(flow_rate * 10):04}", + vt=f"{round(backend_params.transport_air_volume * 10):03}", + bv=f"{round(blow_out_air_volume * 10):05}", + wv=f"{round(backend_params.pre_wetting_volume * 10):05}", + cm=int(backend_params.use_lld), + cs=backend_params.gamma_lld_sensitivity, + bs=f"{round(backend_params.swap_speed * 10):04}", + wh=f"{round(backend_params.settling_time * 10):02}", + hv=f"{round(aspiration.mix.volume * 10):05}" if aspiration.mix is not None else "00000", + hc=f"{aspiration.mix.repetitions:02}" if aspiration.mix is not None else "00", + hp=f"{round(backend_params.mix_position_from_liquid_surface * 10):03}", + mj=f"{round(backend_params.mix_surface_following_distance * 10):03}", + hs=f"{round(aspiration.mix.flow_rate * 10):04}" if aspiration.mix is not None else "1200", + cw=_channel_pattern_to_hex([True] * 96), + cr=f"{backend_params.limit_curve_index:03}", + cj=backend_params.tadm_algorithm, + cx=backend_params.recording_mode, + ) + + # --------------------------------------------------------------------------- + # Dispense + # --------------------------------------------------------------------------- + + @dataclass + class Dispense96Params(BackendParams): + """STAR-specific parameters for 96-head dispense.""" + + jet: bool = False + empty: bool = False + blow_out: bool = False + use_lld: bool = False + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + min_z_endpos: Optional[float] = None + lld_search_height: float = 199.9 + minimum_height: Optional[float] = None + second_section_height: float = 3.2 + second_section_ratio: float = 618.0 + immersion_depth: float = 0 + surface_following_distance: float = 0 + transport_air_volume: float = 5.0 + gamma_lld_sensitivity: int = 1 + swap_speed: float = 2.0 + settling_time: float = 5.0 + mix_position_from_liquid_surface: float = 0 + mix_surface_following_distance: float = 0 + limit_curve_index: int = 0 + cut_off_speed: float = 5.0 + stop_back_volume: float = 0 + pull_out_distance_transport_air: float = 10 + side_touch_off_distance: int = 0 + tadm_algorithm: bool = False + recording_mode: int = 0 + + async def dispense96( + self, + dispense: Union[MultiHeadDispensePlate, MultiHeadDispenseContainer], + backend_params: Optional[BackendParams] = None, + ): + """Dispense using the Core96 head. + + Firmware command: C0 ED + """ + if not isinstance(backend_params, STARHead96Backend.Dispense96Params): + backend_params = STARHead96Backend.Dispense96Params() + + # Compute position + if isinstance(dispense, MultiHeadDispensePlate): + plate = dispense.wells[0].parent + assert plate is not None, "MultiHeadDispensePlate well parent must not be None" + rot = plate.get_absolute_rotation() + if rot.x % 360 != 0 or rot.y % 360 != 0: + raise ValueError("Plate rotation around x or y is not supported for 96 head operations") + if rot.z % 360 == 180: + ref_well = dispense.wells[-1] + elif rot.z % 360 == 0: + ref_well = dispense.wells[0] + else: + raise ValueError("96 head only supports plate rotations of 0 or 180 degrees around z") + + position = ( + ref_well.get_absolute_location() + + ref_well.center() + + Coordinate(z=ref_well.material_z_thickness) + + dispense.offset + ) + else: + # Container (trough): center the head + x_width = (12 - 1) * 9 + y_width = (8 - 1) * 9 + x_position = (dispense.container.get_absolute_size_x() - x_width) / 2 + y_position = (dispense.container.get_absolute_size_y() - y_width) / 2 + y_width + position = ( + dispense.container.get_absolute_location(z="cavity_bottom") + + Coordinate(x=x_position, y=y_position) + + dispense.offset + ) + + tip = next(tip for tip in dispense.tips if tip is not None) + + liquid_height = position.z + (dispense.liquid_height or 0) + + volume = dispense.volume + flow_rate = dispense.flow_rate or 120 + blow_out_air_volume = dispense.blow_out_air_volume or 0 + + dispense_mode = _dispensing_mode_for_op( + empty=backend_params.empty, + jet=backend_params.jet, + blow_out=backend_params.blow_out, + ) + + traversal = self._traversal_height + + immersion_depth = backend_params.immersion_depth + immersion_depth_direction = 0 if immersion_depth >= 0 else 1 + + await self._driver.send_command( + module="C0", + command="ED", + da=dispense_mode, + xs=f"{abs(round(position.x * 10)):05}", + xd=0 if position.x >= 0 else 1, + yh=f"{round(position.y * 10):04}", + zm=f"{round((backend_params.minimum_height or position.z) * 10):04}", + zv=f"{round(backend_params.second_section_height * 10):04}", + zq=f"{round(backend_params.second_section_ratio * 10):05}", + lz=f"{round(backend_params.lld_search_height * 10):04}", + zt=f"{round(liquid_height * 10):04}", + pp=f"{round(backend_params.pull_out_distance_transport_air * 10):04}", + iw=f"{round(abs(immersion_depth) * 10):03}", + ix=immersion_depth_direction, + fh=f"{round(backend_params.surface_following_distance * 10):03}", + zh=f"{round((backend_params.minimum_traverse_height_at_beginning_of_a_command or traversal) * 10):04}", + ze=f"{round((backend_params.min_z_endpos or traversal) * 10):04}", + df=f"{round(volume * 10):05}", + dg=f"{round(flow_rate * 10):04}", + es=f"{round(backend_params.cut_off_speed * 10):04}", + ev=f"{round(backend_params.stop_back_volume * 10):03}", + vt=f"{round(backend_params.transport_air_volume * 10):03}", + bv=f"{round(blow_out_air_volume * 10):05}", + cm=int(backend_params.use_lld), + cs=backend_params.gamma_lld_sensitivity, + ej=f"{backend_params.side_touch_off_distance:02}", + bs=f"{round(backend_params.swap_speed * 10):04}", + wh=f"{round(backend_params.settling_time * 10):02}", + hv=f"{round(dispense.mix.volume * 10):05}" if dispense.mix is not None else "00000", + hc=f"{dispense.mix.repetitions:02}" if dispense.mix is not None else "00", + hp=f"{round(backend_params.mix_position_from_liquid_surface * 10):03}", + mj=f"{round(backend_params.mix_surface_following_distance * 10):03}", + hs=f"{round(dispense.mix.flow_rate * 10):04}" if dispense.mix is not None else "1200", + cw=_channel_pattern_to_hex([True] * 96), + cr=f"{backend_params.limit_curve_index:03}", + cj=backend_params.tadm_algorithm, + cx=backend_params.recording_mode, + ) + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + @staticmethod + def _position_96_head_in_resource(resource) -> Coordinate: + """Compute the A1 position for centering the 96-head in a resource.""" + head_size_x = 9 * 11 # 12 channels, 9mm spacing + head_size_y = 9 * 7 # 8 channels, 9mm spacing + channel_size = 9 + loc = resource.get_absolute_location() + loc.x += (resource.get_size_x() - head_size_x) / 2 + channel_size / 2 + loc.y += (resource.get_size_y() - head_size_y) / 2 + channel_size / 2 + return loc diff --git a/pylabrobot/hamilton/liquid_handlers/star/iswap.py b/pylabrobot/hamilton/liquid_handlers/star/iswap.py index 4eacb09866b..851eb5a7de3 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/iswap.py +++ b/pylabrobot/hamilton/liquid_handlers/star/iswap.py @@ -4,7 +4,7 @@ from pylabrobot.arms.backend import OrientableGripperArmBackend from pylabrobot.arms.standard import GripperLocation from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.legacy.liquid_handling.backends.hamilton.base import HamiltonLiquidHandler +from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver from pylabrobot.resources import Coordinate @@ -22,8 +22,8 @@ def _direction_degrees_to_grip_direction(degrees: float) -> int: class iSWAP(OrientableGripperArmBackend): - def __init__(self, interface: HamiltonLiquidHandler): - self.interface = interface + def __init__(self, driver: STARDriver): + self.driver = driver self._version: Optional[str] = None self._parked: Optional[bool] = None @@ -42,15 +42,16 @@ async def get_gripper_location(self, backend_params=None) -> GripperLocation: raise NotImplementedError("iSWAP does not support get_gripper_location") async def _on_setup(self) -> None: - self._version = await self._request_version() + if self._version is None: + self._version = await self._request_version() async def _request_version(self) -> str: """Request the iSWAP firmware version from the device.""" - return cast(str, (await self.interface.send_command("R0", "RF", fmt="rf" + "&" * 15))["rf"]) + return cast(str, (await self.driver.send_command("R0", "RF", fmt="rf" + "&" * 15))["rf"]) @dataclass class ParkParams(BackendParams): - minimum_traverse_height: float = 284.0 + minimum_traverse_height: float = 280.0 async def park(self, backend_params: Optional[BackendParams] = None) -> None: """Park the iSWAP. @@ -64,7 +65,7 @@ async def park(self, backend_params: Optional[BackendParams] = None) -> None: if not 0 <= backend_params.minimum_traverse_height <= 360.0: raise ValueError("minimum_traverse_height must be between 0 and 360.0") - await self.interface.send_command( + await self.driver.send_command( module="C0", command="PG", th=round(backend_params.minimum_traverse_height * 10), @@ -83,7 +84,7 @@ async def open_gripper( if not 0 <= gripper_width <= 999.9: raise ValueError("gripper_width must be between 0 and 999.9") - await self.interface.send_command( + await self.driver.send_command( module="C0", command="GF", go=f"{round(gripper_width * 10):04}" ) @@ -111,7 +112,7 @@ async def close_gripper( if not 0 <= backend_params.plate_width_tolerance <= 9.9: raise ValueError("plate_width_tolerance must be between 0 and 9.9") - await self.interface.send_command( + await self.driver.send_command( module="C0", command="GC", gw=backend_params.grip_strength, @@ -178,7 +179,7 @@ async def pick_up_at_location( grip_dir = _direction_degrees_to_grip_direction(direction) - await self.interface.send_command( + await self.driver.send_command( module="C0", command="PP", xs=f"{abs(round(location.x * 10)):05}", @@ -249,7 +250,7 @@ async def drop_at_location( grip_dir = _direction_degrees_to_grip_direction(direction) - await self.interface.send_command( + await self.driver.send_command( module="C0", command="PR", xs=f"{abs(round(location.x * 10)):05}", @@ -273,7 +274,7 @@ async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None Returns: True if holding a plate, False otherwise. """ - resp = await self.interface.send_command(module="C0", command="QP", fmt="ph#") + resp = await self.driver.send_command(module="C0", command="QP", fmt="ph#") return resp is not None and resp["ph"] == 1 @dataclass @@ -316,7 +317,7 @@ async def move_to_location( grip_dir = _direction_degrees_to_grip_direction(direction) - await self.interface.send_command( + await self.driver.send_command( module="C0", command="PM", xs=f"{abs(round(location.x * 10)):05}", diff --git a/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md b/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md new file mode 100644 index 00000000000..2390077e54b --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md @@ -0,0 +1,300 @@ +# STAR Architecture + +## Overview + +Split the monolithic `STARBackend` (~12k lines) into the new Driver + CapabilityBackend + Capability + Device architecture. + +**Migrated so far:** +- PIP and Head96 (capability backends) +- iSWAP and CoRe gripper (arm backends) +- AutoLoad, Cover, X-Arms, Wash Station (plain helper classes on the driver) +- ~44 generic driver infrastructure methods (firmware queries, EEPROM, area reservation, configuration) + +## Layers + +``` +STAR (Device) — only exposes Capabilities + _driver ──────────► STARDriver (Driver) + │ │ Owns: USB I/O, firmware protocol, machine config + │ │ + │ ├─ pip: STARPIPBackend (PIPBackend) + │ ├─ head96: STARHead96Backend (Head96Backend) [optional] + │ ├─ iswap: iSWAP (OrientableGripperArmBackend) [optional] + │ ├─ autoload: STARAutoload [optional] + │ ├─ left_x_arm: STARXArm + │ ├─ right_x_arm: STARXArm + │ ├─ cover: STARCover + │ └─ wash_station: STARWashStation [optional] + │ + pip: PIP ──────────► pip backend (above) + head96: Head96 ────► head96 backend (above) + iswap: OrientableArm ► iswap backend (above) +``` + +The STAR device only exposes Capabilities (PIP, Head96, iSWAP). Subsystems (autoload, x-arms, cover, wash station) and generic driver methods live on `star._driver`. + +User code: + +```python +star = STAR(deck=deck) +await star.setup() + +# Capabilities — on the device +await star.pip.pick_up_tips(...) +await star.pip.aspirate(...) +await star.head96.aspirate96(...) +await star.iswap.move_resource(plate, destination) + +# Subsystems — on the driver +await star._driver.autoload.load_carrier(carrier_end_rail=10) +await star._driver.left_x_arm.move_to(500.0) # mm +await star._driver.cover.lock() +await star._driver.wash_station.drain(station=1) + +# Generic driver methods +await star._driver.request_firmware_version() +await star._driver.halt() +``` + +## STARDriver + +Subclass of `HamiltonLiquidHandler` (which extends `Driver`). Owns the USB connection and all firmware protocol logic. + +```python +class STARDriver(HamiltonLiquidHandler): + # Capability backends + pip: STARPIPBackend # always present + head96: Optional[STARHead96Backend] = None # if 96-head installed + iswap: Optional[iSWAP] = None # if iSWAP installed + + # Plain subsystems + autoload: Optional[STARAutoload] = None # if autoload installed + left_x_arm: Optional[STARXArm] = None # always present + right_x_arm: Optional[STARXArm] = None # always present + cover: Optional[STARCover] = None # always present + wash_station: Optional[STARWashStation] = None # always present +``` + +### Responsibilities + +- **USB I/O**: Connect/disconnect via `pylabrobot.io.usb.USB`. Background reading thread for async command/response matching. +- **Firmware protocol**: `send_command(module, command, **params)` assembles the STAR text protocol, sends it, waits for matching response, parses it. +- **Machine configuration**: On `setup()`, queries `RM` (machine config) and `QM` (extended config) to discover installed hardware. Stores as `self.machine_conf` and `self.extended_conf`. +- **Backend/subsystem creation**: During `setup()`, creates backends based on discovered config. Conditional for autoload (needs `auto_load_installed`), Head96 (needs `core_96_head_installed`), iSWAP (needs `iswap_installed`). Unconditional for PIP, X-arms, cover, wash station. +- **Generic instrument operations**: Firmware queries, EEPROM read/write, runtime control (halt, single-step), area reservation, instrument configuration. ~44 methods directly on the driver. +- **Tip type registration**: `_tth2tti` mapping shared across PIP and Head96. +- **Error parsing**: Firmware error codes → Python exceptions. + +### Generic driver methods (directly on STARDriver) + +These are machine-level operations not specific to any capability: + +- **Firmware queries**: `request_firmware_version`, `request_error_code`, `request_master_status`, `request_parameter_value`, `request_eeprom_data_correctness`, `request_electronic_board_type`, `request_supply_voltage`, `request_number_of_presence_sensors_installed` +- **Init/diagnostics**: `request_instrument_initialization_status`, `request_name_of_last_faulty_parameter`, `pre_initialize_instrument` +- **Runtime control**: `set_single_step_mode`, `trigger_next_step`, `halt`, `set_not_stop`, `save_all_cycle_counters` +- **EEPROM write**: `store_installation_data`, `store_verification_data`, `additional_time_stamp`, `save_download_date`, `save_technical_status_of_assemblies`, `set_x_offset_x_axis_*`, `save_pip_channel_validation_status`, `save_xl_channel_validation_status`, `configure_node_names`, `set_deck_data`, `set_instrument_configuration` +- **EEPROM read**: `request_technical_status_of_assemblies`, `request_installation_data`, `request_device_serial_number`, `request_download_date`, `request_verification_data`, `request_additional_timestamp_data`, `request_pip_channel_validation_status`, `request_xl_channel_validation_status`, `request_node_names`, `request_deck_data` +- **X-drive queries**: `request_maximal_ranges_of_x_drives`, `request_present_wrap_size_of_installed_arms` +- **Area reservation**: `occupy_and_provide_area_for_external_access`, `release_occupied_area`, `release_all_occupied_areas` + +## Plain subsystem classes + +These are NOT CapabilityBackends — they're plain helper classes that encapsulate firmware protocol for a subsystem and delegate I/O to the driver via `self._driver.send_command(...)`. + +### STARAutoload + +Controls the autoload module (carrier loading/unloading, barcode scanning, presence detection). + +```python +class STARAutoload: + def __init__(self, driver: STARDriver, instrument_size_slots: int = 54): + self._driver = driver + self._instrument_size_slots = instrument_size_slots +``` + +Key methods: `initialize`, `park`, `move_to_track`, `load_carrier`, `unload_carrier`, `request_presence_of_carriers_on_deck`, `request_presence_of_carriers_on_loading_tray`, `set_loading_indicators`, `verify_and_wait_for_carriers`, barcode operations. + +Methods take `carrier_end_rail: int` instead of `Carrier` objects — the caller computes the rail from carrier geometry. This keeps the class free of deck/resource dependencies. + +### STARXArm + +Controls one X-arm (left or right). One class, parameterized by side — picks the correct firmware command inline. + +```python +class STARXArm: + def __init__(self, driver: STARDriver, side: Literal["left", "right"]): + self._driver = driver + self._side = side +``` + +Methods: `move_to(x_position)` (mm), `move_to_safe(x_position)` (mm), `request_position() -> float` (mm), `last_collision_type() -> bool`. + +Command mapping: +| Operation | Left | Right | +|---|---|---| +| Position (collision risk) | C0:JX | C0:JS | +| Move safe (Z-safety) | C0:KX | C0:KR | +| Request position | C0:RX | C0:QX | +| Last collision type | C0:XX | C0:XR | + +### STARCover + +Controls the front cover. + +```python +class STARCover: + def __init__(self, driver: STARDriver): + self._driver = driver +``` + +Methods: `lock`, `unlock`, `disable`, `enable`, `is_open`, `set_output`, `reset_output`. + +### STARWashStation + +Controls dual-chamber wash/pump stations. + +```python +class STARWashStation: + def __init__(self, driver: STARDriver): + self._driver = driver +``` + +Methods: `request_settings(station)`, `initialize_valves(station)`, `fill_chamber(station, wash_fluid, chamber, ...)`, `drain(station)`. + +## Capability backends + +### STARPIPBackend + +Implements `PIPBackend`. Translates PIP operations into STAR firmware commands. + +Key methods: `pick_up_tips`, `drop_tips`, `aspirate`, `dispense`. + +### STARHead96Backend + +Implements `Head96Backend`. Translates 96-head operations into STAR firmware commands. + +Key methods: `pick_up_tips96`, `drop_tips96`, `aspirate96`, `dispense96`. + +### iSWAP + +Implements `OrientableGripperArmBackend`. Controls the iSWAP plate gripper arm. + +Key methods: `pick_up_at_location`, `drop_at_location`, `park`, `open_gripper`, `close_gripper`. + +### CoreGripper + +Implements `GripperArmBackend`. Uses two PIP channels as a Y-axis gripper. Managed through a context manager on the STAR device. + +```python +async with star.core_grippers(front_channel=7) as arm: + await arm.move_resource(plate, destination) +``` + +## STAR Device + +The user-facing class. Wires driver backends to capability frontends during `setup()`. + +```python +class STAR(Device): + def __init__(self, deck: HamiltonDeck, chatterbox: bool = False): + driver = STARChatterboxDriver() if chatterbox else STARDriver() + super().__init__(driver=driver) + self.deck = deck +``` + +### setup() flow + +``` +await star.setup() + │ + ├─ await STARDriver.setup() + │ 1. Open USB, start background reading thread + │ 2. Query RM → MachineConfiguration + │ 3. Query QM → ExtendedConfiguration + │ 4. Create backends: pip (always), head96 (if installed), iswap (if installed) + │ 5. Create subsystems: autoload (if installed), x_arms, cover, wash_station (if installed) + │ + ├─ Wire capability frontends to backends (on STAR device) + │ self.pip = PIP(backend=driver.pip) + │ self.head96 = Head96Capability(backend=driver.head96) # if installed + │ self.iswap = OrientableArm(backend=driver.iswap) # if installed + │ + └─ Call _on_setup() for each Capability +``` + +Subsystems (autoload, x_arms, cover, wash_station) stay on the driver — the STAR device does NOT re-expose them. Access via `star._driver.autoload`, etc. + +### Optional hardware + +On the device: +```python +star.head96 # None if no 96-head +star.iswap # None if no iSWAP +``` + +On the driver: +```python +star._driver.autoload # None if no autoload module +star._driver.wash_station # None if no wash station +star._driver.left_x_arm # always present +star._driver.right_x_arm # always present +star._driver.cover # always present +``` + +## File structure + +``` +pylabrobot/hamilton/liquid_handlers/star/ + __init__.py # exports STAR, STARAutoload, STARCover, STARXArm, STARWashStation + star.py # STAR(Device) — only capabilities + driver.py # STARDriver + config dataclasses + generic driver methods + chatterbox.py # STARChatterboxDriver (mock for testing) + pip_backend.py # STARPIPBackend(PIPBackend) + head96_backend.py # STARHead96Backend(Head96Backend) + iswap.py # iSWAP(OrientableGripperArmBackend) + core.py # CoreGripper(GripperArmBackend) + autoload.py # STARAutoload (plain class on driver) + cover.py # STARCover (plain class on driver) + x_arm.py # STARXArm (plain class on driver) + wash_station.py # STARWashStation (plain class on driver) + tests/ + autoload_tests.py # 41 tests + cover_tests.py # 11 tests + x_arm_tests.py # 16 tests + wash_station_tests.py # 27 tests + iswap_tests.py # 14 tests + core_tests.py # 7 tests + legacy_parity_tests.py # PIP/Head96 parity tests +``` + +## Legacy compatibility + +The legacy `STARBackend` in `pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py` creates instances of the new classes in its `__init__`: + +```python +self._new_pip = STARPIPBackend(self) +self._new_head96 = STARHead96Backend(self) +self._new_autoload = STARAutoload(driver=self) +self._new_cover = STARCover(driver=self) +self._new_left_x_arm = STARXArm(driver=self, side="left") +self._new_right_x_arm = STARXArm(driver=self, side="right") +self._new_wash_station = STARWashStation(driver=self) +``` + +Migrated methods delegate to these instances (with Carrier→int conversion where needed). All delegating methods have one-line deprecation docstrings: + +```python +async def park_autoload(self): + """Deprecated: use ``star.autoload.park()``.""" + return await self._new_autoload.park() +``` + +Generic driver methods (firmware queries, EEPROM, etc.) exist on both `STARDriver` and the legacy `STARBackend` — the legacy versions have deprecation docstrings but keep their original implementation bodies unchanged. + +## What stays in legacy + +- Probing/LLD: `probe_liquid_heights`, CLLD/PLLD methods +- Foil piercing: `pierce_foil`, `step_off_foil` +- Hotel mode: `put_in_hotel`, `get_from_hotel` +- Heater-shaker: HHC temperature control methods +- Some lower-level PIP/Head96/iSWAP firmware commands not yet migrated to backends diff --git a/pylabrobot/hamilton/liquid_handlers/star/core_test.ipynb b/pylabrobot/hamilton/liquid_handlers/star/misc/core_test.ipynb similarity index 100% rename from pylabrobot/hamilton/liquid_handlers/star/core_test.ipynb rename to pylabrobot/hamilton/liquid_handlers/star/misc/core_test.ipynb diff --git a/pylabrobot/hamilton/liquid_handlers/star/misc/demo.ipynb b/pylabrobot/hamilton/liquid_handlers/star/misc/demo.ipynb new file mode 100644 index 00000000000..d4ff124514f --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/misc/demo.ipynb @@ -0,0 +1,665 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "jxrwhpntxho", + "metadata": {}, + "source": [ + "# STAR Demo\n", + "\n", + "## Part 1: Legacy API\n", + "\n", + "Using `LiquidHandler` + `STARChatterboxBackend` to demonstrate PIP, Head96, iSWAP, and CoRe gripper." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "5pqeiwi4f5b", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "markdown", + "id": "98oeafpq9ws", + "metadata": {}, + "source": [ + "### Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "juixgcyrfkj", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.legacy.liquid_handling import LiquidHandler\n", + "from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_chatterbox import STARChatterboxBackend\n", + "from pylabrobot.resources.hamilton import STARDeck\n", + "from pylabrobot.resources import (\n", + " TIP_CAR_480_A00,\n", + " PLT_CAR_L5AC_A00,\n", + " Cor_96_wellplate_360ul_Fb,\n", + " hamilton_96_tiprack_1000uL_filter,\n", + ")\n", + "\n", + "backend = STARChatterboxBackend()\n", + "deck = STARDeck()\n", + "lh = LiquidHandler(backend=backend, deck=deck)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "hu8hqlq3tia", + "metadata": {}, + "outputs": [], + "source": [ + "# Deck layout\n", + "tip_car = TIP_CAR_480_A00(name=\"tip_carrier\")\n", + "tip_car[0] = hamilton_96_tiprack_1000uL_filter(name=\"tips_01\")\n", + "tip_car[1] = hamilton_96_tiprack_1000uL_filter(name=\"tips_02\")\n", + "deck.assign_child_resource(tip_car, rails=3)\n", + "\n", + "plt_car = PLT_CAR_L5AC_A00(name=\"plate_carrier\")\n", + "plt_car[0] = Cor_96_wellplate_360ul_Fb(name=\"plate_01\")\n", + "plt_car[1] = Cor_96_wellplate_360ul_Fb(name=\"plate_02\")\n", + "deck.assign_child_resource(plt_car, rails=15)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "beczhausu36", + "metadata": {}, + "outputs": [], + "source": [ + "await lh.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "vr6w3hqe9x", + "metadata": {}, + "source": [ + "### PIP: independent channel pipetting" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "60qge4s6wzq", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0TTid0001tt01tf1tl0871tv10650tg3tu0\n", + "C0TPid0002xp01629 01629 01629 00000&yp1458 1368 1278 0000&tm1 1 1 0&tt01tp2266tz2166th2450td0\n", + "C0ASid0003at0 0 0 0&tm1 1 1 0&xp04333 04333 04333 00000&yp1457 1367 1277 0000&th2450te2450lp2000 2000 2000 2000&ch000 000 000 000&zl1866 1866 1866 1866&po0100 0100 0100 0100&zu0032 0032 0032 0032&zr06180 06180 06180 06180&zx1866 1866 1866 1866&ip0000 0000 0000 0000&it0 0 0 0&fp0000 0000 0000 0000&av01083 00563 02110 01083&as2500 2500 2500 2500&ta000 000 000 000&ba0000 0000 0000 0000&oa000 000 000 000&lm0 0 0 0&ll1 1 1 1&lv1 1 1 1&zo000 000 000 000&ld00 00 00 00&de0020 0020 0020 0020&wt10 10 10 10&mv00000 00000 00000 00000&mc00 00 00 00&mp000 000 000 000&ms1000 1000 1000 1000&mh0000 0000 0000 0000&gi000 000 000 000&gj0gk0lk0 0 0 0&ik0000 0000 0000 0000&sd0500 0500 0500 0500&se0500 0500 0500 0500&sz0300 0300 0300 0300&io0000 0000 0000 0000&\n", + "C0DSid0004dm2 2 2 2&tm1 1 1 0&xp04333 04333 04333 00000&yp1187 1097 1007 0000&zx1866 1866 1866 1866&lp2000 2000 2000 2000&zl1866 1866 1866 1866&po0100 0100 0100 0100&ip0000 0000 0000 0000&it0 0 0 0&fp0000 0000 0000 0000&zu0032 0032 0032 0032&zr06180 06180 06180 06180&th2450te2450dv01083 00563 02110 01083&ds1200 1200 1200 1200&ss0050 0050 0050 0050&rv000 000 000 000&ta300 300 300 300&ba0000 0000 0000 0000&lm0 0 0 0&dj00zo000 000 000 000&ll1 1 1 1&lv1 1 1 1&de0020 0020 0020 0020&wt10 10 10 10&mv00000 00000 00000 00000&mc00 00 00 00&mp000 000 000 000&ms0010 0010 0010 0010&mh0000 0000 0000 0000&gi000 000 000 000&gj0gk0\n", + "C0TRid0005xp01629 01629 01629 00000&yp1458 1368 1278 0000&tm1 1 1 0&tp2266tz2186th2450te2450ti1\n" + ] + } + ], + "source": [ + "tiprack = deck.get_resource(\"tips_01\")\n", + "plate = deck.get_resource(\"plate_01\")\n", + "\n", + "# Pick up tips on channels 0-2\n", + "await lh.pick_up_tips(tiprack[\"A1:C1\"])\n", + "\n", + "# Aspirate from wells A1-C1\n", + "await lh.aspirate(plate[\"A1:C1\"], vols=[100.0, 50.0, 200.0])\n", + "\n", + "# Dispense into wells D1-F1\n", + "await lh.dispense(plate[\"D1:F1\"], vols=[100.0, 50.0, 200.0])\n", + "\n", + "# Return tips\n", + "await lh.drop_tips(tiprack[\"A1:C1\"])" + ] + }, + { + "cell_type": "markdown", + "id": "zoszhm7xvkb", + "metadata": {}, + "source": [ + "### Head96: 96-channel head" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "fnm3orv5vah", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "H0DQid0006dq11281dv13500du00000dr900000dw15\n", + "C0EPid0007xs01629xd0yh2418tt01wu0za2166zh2450ze2450\n", + "C0EAid0008aa0xs04333xd0yh1457zh2450ze2450lz1999zt1866pp0100zm1866zv0032zq06180iw000ix0fh000af00500ag2500vt050bv00000wv00050cm0cs1bs0020wh10hv00000hc00hp000mj000hs1200cwFFFFFFFFFFFFFFFFFFFFFFFFcr000cj0cx0\n", + "C0EDid0009da2xs04333xd0yh1457zm1866zv0032zq06180lz1999zt1866pp0100iw000ix0fh000zh2450ze2450df00500dg1200es0050ev000vt050bv00000cm0cs1ej00bs0020wh50hv00000hc00hp000mj000hs1200cwFFFFFFFFFFFFFFFFFFFFFFFFcr000cj0cx0\n", + "C0ERid0010xs01629xd0yh2418za2164zh2450ze2450\n" + ] + } + ], + "source": [ + "tiprack96 = deck.get_resource(\"tips_02\")\n", + "\n", + "await lh.pick_up_tips96(tiprack96)\n", + "await lh.aspirate96(plate, volume=50)\n", + "await lh.dispense96(plate, volume=50)\n", + "await lh.drop_tips96(tiprack96)" + ] + }, + { + "cell_type": "markdown", + "id": "xlfogucr4i", + "metadata": {}, + "source": [ + "### iSWAP: plate gripper arm" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "abkpgbe5u1t", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0PPid0011xs04829xd0yj1142yd0zj1841zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0\n", + "C0PRid0012xs04829xd0yj3062yd0zj1841zd0th2800te2800gr1go1308ga0gc0\n" + ] + } + ], + "source": [ + "# Move plate_01 from carrier slot 0 to slot 1 using iSWAP\n", + "await lh.move_resource(plate, plt_car[2], pickup_distance_from_top=13.2)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "qfpdyxxfkr", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0PPid0013xs04829xd0yj3062yd0zj1841zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0\n", + "C0PRid0014xs04829xd0yj1142yd0zj1841zd0th2800te2800gr1go1308ga0gc0\n" + ] + } + ], + "source": [ + "# Move it back\n", + "await lh.move_resource(plate, plt_car[0], pickup_distance_from_top=13.2)" + ] + }, + { + "cell_type": "markdown", + "id": "w0dhs17knr", + "metadata": {}, + "source": [ + "### CoRe gripper: channel-mounted plate gripper" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "i8sjlvr0k0i", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0PGid0015th2800\n", + "C0ZPid0016xs04829xd0yj2102yv0050zj1841zy0500yo0885yg0825yw15th2800te2800\n", + "C0ZRid0017xs04829xd0yj3062zj1841zi000zy0500yo0885th2800te2800\n", + "C0ZSid0018xs13375xd0ya1250yb1070tp2150tz2050th2800te2800\n" + ] + } + ], + "source": [ + "plate2 = deck.get_resource(\"plate_02\")\n", + "\n", + "# Move plate_02 using CoRe gripper (channels 7+8)\n", + "await lh.move_resource(\n", + " plate2,\n", + " plt_car[2],\n", + " pickup_distance_from_top=13.2,\n", + " use_arm=\"core\",\n", + " core_front_channel=7,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "bpmwb8wj95u", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0ZTid0019xs13375xd0ya1250yb1070pa07pb08tp2350tz2250th2800tt14\n", + "C0ZPid0020xs04829xd0yj3062yv0050zj1841zy0500yo0885yg0825yw15th2800te2800\n", + "C0ZRid0021xs04829xd0yj2102zj1841zi000zy0500yo0885th2800te2800\n", + "C0ZSid0022xs13375xd0ya1250yb1070tp2150tz2050th2800te2800\n" + ] + } + ], + "source": [ + "# Move it back\n", + "await lh.move_resource(\n", + " plate2,\n", + " plt_car[1],\n", + " pickup_distance_from_top=13.2,\n", + " use_arm=\"core\",\n", + " core_front_channel=7,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "dddt87cvc9u", + "metadata": {}, + "source": [ + "### Cleanup" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "ks7rs90y9f", + "metadata": {}, + "outputs": [], + "source": [ + "await lh.stop()" + ] + }, + { + "cell_type": "markdown", + "id": "dqbqe4pi3f4", + "metadata": {}, + "source": [ + "## Part 2: New API\n", + "\n", + "Using `STAR` device + `STARChatterboxDriver` with the new capability architecture." + ] + }, + { + "cell_type": "markdown", + "id": "irhljy3gm", + "metadata": {}, + "source": [ + "### Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "gnoke5r5gpa", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.hamilton.liquid_handlers.star.star import STAR\n", + "from pylabrobot.resources.hamilton import STARDeck\n", + "from pylabrobot.resources import (\n", + " TIP_CAR_480_A00,\n", + " PLT_CAR_L5AC_A00,\n", + " Cor_96_wellplate_360ul_Fb,\n", + " hamilton_96_tiprack_1000uL_filter,\n", + ")\n", + "\n", + "deck2 = STARDeck()\n", + "star = STAR(deck=deck2, chatterbox=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "2ubkc30t4fx", + "metadata": {}, + "outputs": [], + "source": [ + "# Deck layout\n", + "tip_car2 = TIP_CAR_480_A00(name=\"tip_carrier_2\")\n", + "tip_car2[0] = hamilton_96_tiprack_1000uL_filter(name=\"tips_2_01\")\n", + "tip_car2[1] = hamilton_96_tiprack_1000uL_filter(name=\"tips_2_02\")\n", + "deck2.assign_child_resource(tip_car2, rails=3)\n", + "\n", + "plt_car2 = PLT_CAR_L5AC_A00(name=\"plate_carrier_2\")\n", + "plt_car2[0] = Cor_96_wellplate_360ul_Fb(name=\"plate_2_01\")\n", + "plt_car2[1] = Cor_96_wellplate_360ul_Fb(name=\"plate_2_02\")\n", + "deck2.assign_child_resource(plt_car2, rails=15)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "z7mk4dfm409", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "pip: 8 channels\n", + "head96: True\n", + "iswap: True\n" + ] + } + ], + "source": [ + "await star.setup()\n", + "\n", + "print(f\"pip: {star.pip.num_channels} channels\")\n", + "print(f\"head96: {star.head96 is not None}\")\n", + "print(f\"iswap: {star.iswap is not None}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "c7fa139d", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[,\n", + " ,\n", + " ]" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "star._capabilities" + ] + }, + { + "cell_type": "markdown", + "id": "s6bi76rsp1", + "metadata": {}, + "source": [ + "### PIP: independent channel pipetting\n", + "\n", + "TODO: implement `STARPIPBackend.pick_up_tips`, `aspirate`, `dispense`, `drop_tips`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "iuekwcjpdif", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0TTid0001tt01tf1tl0871tv10650tg3tu0\n", + "C0TPid0002xp01629 01629 01629 00000&yp1458 1368 1278 0000&tm1 1 1 0&tt01tp2266tz2166th2450td0\n", + "C0ASid0003at0 0 0 0&tm1 1 1 0&xp04333 04333 04333 00000&yp1457 1367 1277 0000&th2450te2450lp2000 2000 2000 2000&ch000 000 000 000&zl1866 1866 1866 1866&po0100 0100 0100 0100&zu0032 0032 0032 0032&zr06180 06180 06180 06180&zx1866 1866 1866 1866&ip0000 0000 0000 0000&it0 0 0 0&fp0000 0000 0000 0000&av01000 00500 02000 01000&as1000 1000 1000 1000&ta000 000 000 000&ba0000 0000 0000 0000&oa000 000 000 000&lm0 0 0 0&ll1 1 1 1&lv1 1 1 1&zo000 000 000 000&ld00 00 00 00&de1000 1000 1000 1000&wt00 00 00 00&mv00000 00000 00000 00000&mc00 00 00 00&mp000 000 000 000&ms1000 1000 1000 1000&mh0000 0000 0000 0000&gi000 000 000 000&gj0gk0lk0 0 0 0&ik0000 0000 0000 0000&sd0500 0500 0500 0500&se0500 0500 0500 0500&sz0300 0300 0300 0300&io0000 0000 0000 0000&\n", + "C0DSid0004dm2 2 2 2&tm1 1 1 0&xp04333 04333 04333 00000&yp1187 1097 1007 0000&zx1866 1866 1866 1866&lp2000 2000 2000 2000&zl1866 1866 1866 1866&po0100 0100 0100 0100&ip0000 0000 0000 0000&it0 0 0 0&fp0000 0000 0000 0000&zu0032 0032 0032 0032&zr06180 06180 06180 06180&th2450te2450dv01000 00500 02000 01000&ds1200 1200 1200 1200&ss0050 0050 0050 0050&rv000 000 000 000&ta000 000 000 000&ba0000 0000 0000 0000&lm0 0 0 0&dj00zo000 000 000 000&ll1 1 1 1&lv1 1 1 1&de0100 0100 0100 0100&wt00 00 00 00&mv00000 00000 00000 00000&mc00 00 00 00&mp000 000 000 000&ms0010 0010 0010 0010&mh0000 0000 0000 0000&gi000 000 000 000&gj0gk0\n", + "C0TRid0005xp01629 01629 01629 00000&yp1458 1368 1278 0000&tm1 1 1 0&tp2266tz2186th2450te2450ti1\n" + ] + } + ], + "source": [ + "tiprack = deck2.get_resource(\"tips_2_01\")\n", + "plate = deck2.get_resource(\"plate_2_01\")\n", + "\n", + "await star.pip.pick_up_tips(tiprack[\"A1:C1\"])\n", + "await star.pip.aspirate(plate[\"A1:C1\"], vols=[100.0, 50.0, 200.0])\n", + "await star.pip.dispense(plate[\"D1:F1\"], vols=[100.0, 50.0, 200.0])\n", + "await star.pip.drop_tips(tiprack[\"A1:C1\"])" + ] + }, + { + "cell_type": "markdown", + "id": "xg95f0pzo5h", + "metadata": {}, + "source": [ + "### Head96: 96-channel head\n", + "\n", + "TODO: implement `STARHead96Backend.pick_up_tips96`, `aspirate96`, `dispense96`, `drop_tips96`" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "5a33abcd", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "star.head96.backend is star._driver.head96" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "samnutfxon", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "H0DQid0006dq11281dv13500du00000dr900000dw15\n", + "C0EPid0007xs01629xd0yh2418tt01wu0za2166zh2450ze2450\n", + "C0EAid0008aa0xs04333xd0yh1457zh2450ze2450lz1999zt1866pp0100zm1866zv0032zq06180iw000ix0fh000af00500ag2500vt050bv00000wv00050cm0cs1bs0020wh10hv00000hc00hp000mj000hs1200cwFFFFFFFFFFFFFFFFFFFFFFFFcr000cj0cx0\n", + "C0EDid0009da2xs04333xd0yh1457zm1866zv0032zq06180lz1999zt1866pp0100iw000ix0fh000zh2450ze2450df00500dg1200es0050ev000vt050bv00000cm0cs1ej00bs0020wh50hv00000hc00hp000mj000hs1200cwFFFFFFFFFFFFFFFFFFFFFFFFcr000cj0cx0\n", + "C0ERid0010xs01629xd0yh2418za2164zh2450ze2450\n" + ] + } + ], + "source": [ + "from pylabrobot.hamilton.liquid_handlers.star.head96_backend import STARHead96Backend\n", + "\n", + "\n", + "tiprack96 = deck2.get_resource(\"tips_2_02\")\n", + "\n", + "await star.head96.pick_up_tips(tiprack96)\n", + "await star.head96.aspirate(plate, volume=50)\n", + "await star.head96.dispense(plate, volume=50)\n", + "await star.head96.drop_tips(tiprack96)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "119db8a2", + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "id": "1he3sa6jysm", + "metadata": {}, + "source": [ + "### iSWAP: plate gripper arm\n", + "\n", + "The iSWAP backend is already implemented. The `OrientableArm` frontend provides `pick_up_resource` / `drop_resource` / `move_resource`." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "2f5285ac", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "star.iswap.backend is star._driver.iswap" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "7zd9oqj09e7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0PPid0011xs04829xd0yj1142yd0zj1841zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0\n", + "C0PRid0012xs04829xd0yj3062yd0zj1841zd0th2800te2800gr1go1308ga0gc0\n" + ] + } + ], + "source": [ + "plate2 = deck2.get_resource(\"plate_2_01\")\n", + "\n", + "# Move plate between carrier slots using iSWAP\n", + "await star.iswap.move_resource(plate2, plt_car2[2], pickup_distance_from_top=13.2)" + ] + }, + { + "cell_type": "markdown", + "id": "6e2ed94e", + "metadata": {}, + "source": [ + "Moving using a function that takes any `OrientableArm`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d5b972f7", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0PPid0013xs04829xd0yj3062yd0zj1841zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0\n", + "C0PRid0014xs04829xd0yj1142yd0zj1841zd0th2800te2800gr1go1308ga0gc0\n" + ] + } + ], + "source": [ + "from pylabrobot.arms.orientable_arm import OrientableArm\n", + "\n", + "\n", + "async def move_resource(resource, destination, arm: OrientableArm):\n", + " await arm.move_resource(resource, destination, pickup_distance_from_top=13.2)\n", + "\n", + "await move_resource(plate2, plt_car2[0], arm=star.iswap)\n", + "# await move_resource(plate2, plt_car2[0], arm=pf400.arm)" + ] + }, + { + "cell_type": "markdown", + "id": "ukuz7tzhblk", + "metadata": {}, + "source": [ + "### CoRe gripper" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aa9e7f07", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "C0PGid0015th2800\n", + "C0ZTid0016xs13375xd0ya1250yb1070pa07pb08tp2350tz2250th2800tt14\n", + "arm type: \n", + "C0ZPid0017xs04829xd0yj1142yv0050zj1841zy0500yo0885yg0825yw15th2800te2800\n", + "C0ZRid0018xs04829xd0yj3062zj1841zi000zy0500yo0885th2800te2800\n", + "C0ZSid0019xs13375xd0ya1250yb1070tp2150tz2050th2800te2800\n" + ] + } + ], + "source": [ + "async with star.core_grippers(front_channel=7) as arm:\n", + " await move_resource(plate2, plt_car2[2], arm=arm)" + ] + }, + { + "cell_type": "markdown", + "id": "w7o5tu6h8sd", + "metadata": {}, + "source": [ + "### Cleanup" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "9fzkiqj66wa", + "metadata": {}, + "outputs": [], + "source": [ + "# await star.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pylabrobot/hamilton/liquid_handlers/star/iswap_test.ipynb b/pylabrobot/hamilton/liquid_handlers/star/misc/iswap_test.ipynb similarity index 100% rename from pylabrobot/hamilton/liquid_handlers/star/iswap_test.ipynb rename to pylabrobot/hamilton/liquid_handlers/star/misc/iswap_test.ipynb diff --git a/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py new file mode 100644 index 00000000000..cd3c0282b97 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py @@ -0,0 +1,1034 @@ +"""STAR PIP backend: translates PIP operations into STAR firmware commands.""" + +from __future__ import annotations + +import enum +import logging +from dataclasses import dataclass +from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend +from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense, Pickup, TipDrop +from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton import ( + HamiltonLiquidClass, + get_star_liquid_class, +) +from pylabrobot.resources import Tip, TipSpot, Well +from pylabrobot.resources.hamilton import HamiltonTip, TipDropMethod, TipPickupMethod, TipSize +from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import ( + STARFirmwareError, + convert_star_firmware_error_to_plr_error, +) +from pylabrobot.resources.liquid import Liquid + +if TYPE_CHECKING: + from .driver import STARDriver + +logger = logging.getLogger("pylabrobot") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _ops_to_fw_positions( + ops: Sequence[Union[Pickup, TipDrop, Aspiration, Dispense]], + use_channels: List[int], + num_channels: int, +) -> Tuple[List[int], List[int], List[bool]]: + """Convert ops + use_channels into firmware x/y positions and tip pattern. + + Uses absolute coordinates (get_absolute_location) so the driver does not + need a ``deck`` reference. This mirrors HamiltonLiquidHandler._ops_to_fw_positions + but is self-contained. + """ + assert use_channels == sorted(use_channels), "Channels must be sorted." + + x_positions: List[int] = [] + y_positions: List[int] = [] + channels_involved: List[bool] = [] + + for i, channel in enumerate(use_channels): + # Pad unused channels with zeros. + while channel > len(channels_involved): + channels_involved.append(False) + x_positions.append(0) + y_positions.append(0) + channels_involved.append(True) + + loc = ops[i].resource.get_absolute_location(x="c", y="c", z="b") + x_positions.append(round((loc.x + ops[i].offset.x) * 10)) + y_positions.append(round((loc.y + ops[i].offset.y) * 10)) + + # Minimum distance check (9mm per channel index difference). + for idx1, (x1, y1) in enumerate(zip(x_positions, y_positions)): + for idx2, (x2, y2) in enumerate(zip(x_positions, y_positions)): + if idx1 == idx2: + continue + if not channels_involved[idx1] or not channels_involved[idx2]: + continue + if x1 != x2: + continue + if y1 != y2 and abs(y1 - y2) < 90: + raise ValueError( + f"Minimum distance between two y positions is <9mm: {y1}, {y2}" + f" (channel {idx1} and {idx2})" + ) + + if len(ops) > num_channels: + raise ValueError(f"Too many channels specified: {len(ops)} > {num_channels}") + + # Trailing padding (STAR firmware expects at least one extra slot when < num_channels). + if len(x_positions) < num_channels: + x_positions = x_positions + [0] + y_positions = y_positions + [0] + channels_involved = channels_involved + [False] + + return x_positions, y_positions, channels_involved + + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class LLDMode(enum.Enum): + """Liquid level detection mode.""" + + OFF = 0 + GAMMA = 1 + PRESSURE = 2 + DUAL = 3 + Z_TOUCH_OFF = 4 + + +# --------------------------------------------------------------------------- +# Utility +# --------------------------------------------------------------------------- + +_DEFAULT_TRAVERSAL_HEIGHT = 245.0 # mm (matches legacy _channel_traversal_height) + + +def _resolve_liquid_classes( + explicit: Optional[List[Optional[HamiltonLiquidClass]]], + ops: list, + jet: Union[bool, List[bool]], + blow_out: Union[bool, List[bool]], + is_aspirate: bool, +) -> List[Optional[HamiltonLiquidClass]]: + """Resolve per-op Hamilton liquid classes. + + If ``explicit`` is None, auto-detect from tip properties for each op. + If ``explicit`` is a list, use it as-is (None entries stay None, matching legacy behavior). + """ + n = len(ops) + if isinstance(jet, bool): + jet = [jet] * n + if isinstance(blow_out, bool): + blow_out = [blow_out] * n + + if explicit is not None: + return list(explicit) + + result: List[Optional[HamiltonLiquidClass]] = [] + for i, op in enumerate(ops): + tip = op.tip + if not isinstance(tip, HamiltonTip): + result.append(None) + continue + result.append(get_star_liquid_class( + tip_volume=tip.maximal_volume, + is_core=False, + is_tip=True, + has_filter=tip.has_filter, + liquid=Liquid.WATER, + jet=jet[i], + blow_out=blow_out[i], + )) + + return result + + +def _fill(val: Optional[List], default: List) -> List: + """Return *val* if given, otherwise *default*. Replace per-element None with default.""" + if val is None: + return default + if len(val) != len(default): + raise ValueError(f"Value length must equal num operations ({len(default)}), but is {len(val)}") + return [v if v is not None else d for v, d in zip(val, default)] + + +def _dispensing_mode_for_op(empty: bool, jet: bool, blow_out: bool) -> int: + if empty: + return 4 + if jet: + return 1 if blow_out else 0 + return 3 if blow_out else 2 + + +# --------------------------------------------------------------------------- +# STARPIPBackend +# --------------------------------------------------------------------------- + + +def _assert_range(values, lo, hi, name): + """Assert all values in a list are within [lo, hi].""" + if not all(lo <= v <= hi for v in values): + raise ValueError(f"{name} values must be between {lo} and {hi}, got {values}") + + +class STARPIPBackend(PIPBackend): + """Translates PIP operations into STAR firmware commands via the driver.""" + + def __init__(self, driver: STARDriver): + self._driver = driver + + @property + def num_channels(self) -> int: + return self._driver.num_channels + + async def _ensure_iswap_parked(self) -> None: + """Park the iSWAP if it is installed and not already parked.""" + iswap = getattr(self._driver, 'iswap', None) + if iswap is not None and hasattr(iswap, 'parked') and not iswap.parked: + await iswap.park() + + def _ensure_can_reach_position( + self, + use_channels: List[int], + ops: Sequence[Union[Pickup, TipDrop, Aspiration, Dispense]], + op_name: str, + ) -> None: + """Validate that each channel can physically reach its target Y position.""" + if self._driver.extended_conf is None: + return # skip validation if config not available (e.g. chatterbox) + ext = self._driver.extended_conf + spacings = getattr(self._driver, '_channels_minimum_y_spacing', None) + if spacings is None: + spacings = [ext.min_raster_pitch_pip_channels] * self.num_channels + + cant_reach = [] + for channel_idx, op in zip(use_channels, ops): + loc = op.resource.get_absolute_location(x="c", y="c", z="b") + op.offset + min_y = ext.left_arm_min_y_position + sum(spacings[channel_idx + 1:]) + max_y = ext.pip_maximal_y_position - sum(spacings[:channel_idx]) + if loc.y < min_y or loc.y > max_y: + cant_reach.append(channel_idx) + + if cant_reach: + raise ValueError( + f"Channels {cant_reach} cannot reach their target positions in '{op_name}' operation.\n" + "Robots with more than 8 channels have limited Y-axis reach per channel." + ) + + # -- pick_up_tips ----------------------------------------------------------- + + @dataclass + class PickUpTipsParams(BackendParams): + """STAR-specific parameters for ``pick_up_tips``.""" + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + pickup_method: Optional[TipPickupMethod] = None + begin_tip_pick_up_process: Optional[float] = None + end_tip_pick_up_process: Optional[float] = None + + async def pick_up_tips( + self, + ops: List[Pickup], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, STARPIPBackend.PickUpTipsParams): + backend_params = STARPIPBackend.PickUpTipsParams() + + await self._ensure_iswap_parked() + self._ensure_can_reach_position(use_channels, ops, "pick_up_tips") + + x_positions, y_positions, channels_involved = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + # Tip type registration. + tips = set() + for op in ops: + tip = op.tip + if not isinstance(tip, HamiltonTip): + raise TypeError(f"Tip {tip} is not a HamiltonTip.") + tips.add(tip) + if len(tips) > 1: + raise ValueError("Cannot mix tips with different tip types.") + ham_tip = tips.pop() + assert isinstance(ham_tip, HamiltonTip) + ttti = await self._driver.get_or_assign_tip_type_index(ham_tip) + + # Z computations (absolute coordinates). + max_z = max( + op.resource.get_absolute_location(x="c", y="c", z="b").z + op.offset.z for op in ops + ) + max_total_tip_length = max(op.tip.total_tip_length for op in ops) + max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + + if ham_tip.tip_size == TipSize.LOW_VOLUME: + max_tip_length += 2 + elif ham_tip.tip_size != TipSize.STANDARD_VOLUME: + max_tip_length -= 2 + + begin_tip_pick_up_process = ( + round(backend_params.begin_tip_pick_up_process * 10) + if backend_params.begin_tip_pick_up_process is not None + else round((max_z + max_total_tip_length) * 10) + ) + end_tip_pick_up_process = ( + round(backend_params.end_tip_pick_up_process * 10) + if backend_params.end_tip_pick_up_process is not None + else round((max_z + max_tip_length) * 10) + ) + + minimum_traverse_height_at_beginning_of_a_command = round( + (backend_params.minimum_traverse_height_at_beginning_of_a_command or _DEFAULT_TRAVERSAL_HEIGHT) + * 10 + ) + + pickup_method = backend_params.pickup_method or ham_tip.pickup_method + + # Range validation (matches legacy pick_up_tip assertions). + _assert_range(x_positions, 0, 25000, "x_positions") + _assert_range(y_positions, 0, 6500, "y_positions") + assert 0 <= begin_tip_pick_up_process <= 3600, "begin_tip_pick_up_process must be 0-3600" + assert 0 <= end_tip_pick_up_process <= 3600, "end_tip_pick_up_process must be 0-3600" + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + + try: + await self._driver.send_command( + module="C0", + command="TP", + tip_pattern=channels_involved, + read_timeout=max(120, self._driver.read_timeout), + xp=[f"{x:05}" for x in x_positions], + yp=[f"{y:04}" for y in y_positions], + tm=channels_involved, + tt=f"{ttti:02}", + tp=f"{begin_tip_pick_up_process:04}", + tz=f"{end_tip_pick_up_process:04}", + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + td=pickup_method.value, + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise + + # -- drop_tips -------------------------------------------------------------- + + @dataclass + class DropTipsParams(BackendParams): + """STAR-specific parameters for ``drop_tips``.""" + drop_method: Optional[TipDropMethod] = None + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + z_position_at_end_of_a_command: Optional[float] = None + begin_tip_deposit_process: Optional[float] = None + end_tip_deposit_process: Optional[float] = None + + async def drop_tips( + self, + ops: List[TipDrop], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, STARPIPBackend.DropTipsParams): + backend_params = STARPIPBackend.DropTipsParams() + + await self._ensure_iswap_parked() + self._ensure_can_reach_position(use_channels, ops, "drop_tips") + + drop_method = backend_params.drop_method + if drop_method is None: + if any(not isinstance(op.resource, TipSpot) for op in ops): + drop_method = TipDropMethod.PLACE_SHIFT + else: + drop_method = TipDropMethod.DROP + + x_positions, y_positions, channels_involved = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + max_z = max( + op.resource.get_absolute_location(x="c", y="c", z="b").z + op.offset.z for op in ops + ) + + if backend_params.begin_tip_deposit_process is not None: + begin_tip_deposit_process = round(backend_params.begin_tip_deposit_process * 10) + elif drop_method == TipDropMethod.PLACE_SHIFT: + begin_tip_deposit_process = round((max_z + 59.9) * 10) + else: + max_total_tip_length = max(op.tip.total_tip_length for op in ops) + begin_tip_deposit_process = round((max_z + max_total_tip_length) * 10) + + if backend_params.end_tip_deposit_process is not None: + end_tip_deposit_process = round(backend_params.end_tip_deposit_process * 10) + elif drop_method == TipDropMethod.PLACE_SHIFT: + end_tip_deposit_process = round((max_z + 49.9) * 10) + else: + max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + end_tip_deposit_process = round((max_z + max_tip_length) * 10) + + minimum_traverse_height_at_beginning_of_a_command = round( + (backend_params.minimum_traverse_height_at_beginning_of_a_command or _DEFAULT_TRAVERSAL_HEIGHT) + * 10 + ) + z_position_at_end_of_a_command = round( + (backend_params.z_position_at_end_of_a_command or _DEFAULT_TRAVERSAL_HEIGHT) * 10 + ) + + # Range validation (matches legacy discard_tip assertions). + _assert_range(x_positions, 0, 25000, "x_positions") + _assert_range(y_positions, 0, 6500, "y_positions") + assert 0 <= begin_tip_deposit_process <= 3600, "begin_tip_deposit_process must be 0-3600" + assert 0 <= end_tip_deposit_process <= 3600, "end_tip_deposit_process must be 0-3600" + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + assert 0 <= z_position_at_end_of_a_command <= 3600 + + try: + await self._driver.send_command( + module="C0", + command="TR", + tip_pattern=channels_involved, + read_timeout=max(120, self._driver.read_timeout), + xp=[f"{x:05}" for x in x_positions], + yp=[f"{y:04}" for y in y_positions], + tm=channels_involved, + tp=begin_tip_deposit_process, + tz=end_tip_deposit_process, + th=minimum_traverse_height_at_beginning_of_a_command, + te=z_position_at_end_of_a_command, + ti=drop_method.value, + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise + + # -- aspirate --------------------------------------------------------------- + + @dataclass + class AspirateParams(BackendParams): + """STAR-specific parameters for ``aspirate``.""" + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None + disable_volume_correction: Optional[List[bool]] = None + aspiration_type: Optional[List[int]] = None + jet: Optional[List[bool]] = None + blow_out: Optional[List[bool]] = None + lld_search_height: Optional[List[float]] = None + clot_detection_height: Optional[List[float]] = None + pull_out_distance_transport_air: Optional[List[float]] = None + second_section_height: Optional[List[float]] = None + second_section_ratio: Optional[List[float]] = None + minimum_height: Optional[List[float]] = None + immersion_depth: Optional[List[float]] = None + """Positive = go deeper into liquid, negative = go up out of liquid.""" + surface_following_distance: Optional[List[float]] = None + transport_air_volume: Optional[List[float]] = None + pre_wetting_volume: Optional[List[float]] = None + lld_mode: Optional[List[LLDMode]] = None + gamma_lld_sensitivity: Optional[List[int]] = None + dp_lld_sensitivity: Optional[List[int]] = None + aspirate_position_above_z_touch_off: Optional[List[float]] = None + detection_height_difference_for_dual_lld: Optional[List[float]] = None + swap_speed: Optional[List[float]] = None + settling_time: Optional[List[float]] = None + mix_position_from_liquid_surface: Optional[List[float]] = None + mix_surface_following_distance: Optional[List[float]] = None + limit_curve_index: Optional[List[int]] = None + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + min_z_endpos: Optional[float] = None + liquid_surface_no_lld: Optional[List[float]] = None + use_2nd_section_aspiration: Optional[List[bool]] = None + retract_height_over_2nd_section_to_empty_tip: Optional[List[float]] = None + dispensation_speed_during_emptying_tip: Optional[List[float]] = None + dosing_drive_speed_during_2nd_section_search: Optional[List[float]] = None + z_drive_speed_during_2nd_section_search: Optional[List[float]] = None + cup_upper_edge: Optional[List[float]] = None + tadm_algorithm: bool = False + recording_mode: int = 0 + probe_liquid_height: bool = False + auto_surface_following_distance: bool = False + + async def aspirate( + self, + ops: List[Aspiration], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, STARPIPBackend.AspirateParams): + backend_params = STARPIPBackend.AspirateParams() + + await self._ensure_iswap_parked() + self._ensure_can_reach_position(use_channels, ops, "aspirate") + + x_positions, y_positions, channels_involved = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + n = len(ops) + + # Resolve liquid classes (auto-detect from tip if not provided). + hlcs = _resolve_liquid_classes(backend_params.hamilton_liquid_classes, ops, + jet=backend_params.jet or False, + blow_out=backend_params.blow_out or False, + is_aspirate=True) + + # Well bottoms (absolute z + material thickness). + well_bottoms = [ + op.resource.get_absolute_location(x="c", y="c", z="b").z + + op.offset.z + + op.resource.material_z_thickness + for op in ops + ] + + # LLD search height. + if backend_params.lld_search_height is None: + lld_search_height = [ + wb + op.resource.get_absolute_size_z() + (2.7 if isinstance(op.resource, Well) else 5) + for wb, op in zip(well_bottoms, ops) + ] + else: + lld_search_height = [ + wb + sh for wb, sh in zip(well_bottoms, backend_params.lld_search_height) + ] + + clot_detection_height = _fill(backend_params.clot_detection_height, + [hlc.aspiration_clot_retract_height if hlc is not None else 0.0 for hlc in hlcs]) + pull_out_distance_transport_air = _fill( + backend_params.pull_out_distance_transport_air, [10.0] * n + ) + second_section_height = _fill(backend_params.second_section_height, [3.2] * n) + second_section_ratio = _fill(backend_params.second_section_ratio, [618.0] * n) + minimum_height = _fill(backend_params.minimum_height, well_bottoms) + + immersion_depth_raw = backend_params.immersion_depth or [0.0] * n + immersion_depth_direction = backend_params.immersion_depth_direction or [ + 0 if id_ >= 0 else 1 for id_ in immersion_depth_raw + ] + immersion_depth = [ + im * (-1 if immersion_depth_direction[i] else 1) + for i, im in enumerate(immersion_depth_raw) + ] + + surface_following_distance = _fill(backend_params.surface_following_distance, [0.0] * n) + + # Volumes (with liquid class correction). + disable_vc = _fill(backend_params.disable_volume_correction, [False] * n) + volumes = [ + hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume + for op, hlc, disabled in zip(ops, hlcs, disable_vc) + ] + + # Flow rates (liquid class default). + flow_rates = [ + op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100.0) + for op, hlc in zip(ops, hlcs) + ] + + transport_air_volume = _fill(backend_params.transport_air_volume, + [hlc.aspiration_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs]) + blow_out_air_volumes = [ + op.blow_out_air_volume or (hlc.aspiration_blow_out_volume if hlc is not None else 0.0) + for op, hlc in zip(ops, hlcs) + ] + pre_wetting_volume = _fill(backend_params.pre_wetting_volume, [0.0] * n) + lld_mode = _fill( + backend_params.lld_mode, [LLDMode.OFF] * n + ) + gamma_lld_sensitivity = _fill(backend_params.gamma_lld_sensitivity, [1] * n) + dp_lld_sensitivity = _fill(backend_params.dp_lld_sensitivity, [1] * n) + aspirate_position_above_z_touch_off = _fill( + backend_params.aspirate_position_above_z_touch_off, [0.0] * n + ) + detection_height_difference_for_dual_lld = _fill( + backend_params.detection_height_difference_for_dual_lld, [0.0] * n + ) + swap_speed = _fill(backend_params.swap_speed, + [hlc.aspiration_swap_speed if hlc is not None else 100.0 for hlc in hlcs]) + settling_time = _fill(backend_params.settling_time, + [hlc.aspiration_settling_time if hlc is not None else 0.0 for hlc in hlcs]) + + # Mix. + mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_position_from_liquid_surface = _fill( + backend_params.mix_position_from_liquid_surface, [0.0] * n + ) + mix_speed = [op.mix.flow_rate if op.mix is not None else 100.0 for op in ops] + mix_surface_following_distance = _fill( + backend_params.mix_surface_following_distance, [0.0] * n + ) + limit_curve_index = _fill(backend_params.limit_curve_index, [0] * n) + + # Probe liquid height if requested. + traverse_height_override = backend_params.minimum_traverse_height_at_beginning_of_a_command + if backend_params.probe_liquid_height: + if any(op.liquid_height is not None for op in ops): + raise ValueError("Cannot use probe_liquid_height when liquid heights are set.") + liquid_heights = await self._driver.probe_liquid_heights( + containers=[op.resource for op in ops], + use_channels=use_channels, + resource_offsets=[op.offset for op in ops], + move_to_z_safety_after=False, + ) + logger.info("Detected liquid heights: %s", liquid_heights) + traverse_height_override = 100.0 + else: + liquid_heights = [op.liquid_height or 0.0 for op in ops] + + # Auto surface following distance. + if backend_params.auto_surface_following_distance: + if any(op.liquid_height is None for op in ops) and not backend_params.probe_liquid_height: + raise ValueError( + "To use auto_surface_following_distance all liquid heights must be set or " + "probe_liquid_height must be True." + ) + if any(not op.resource.supports_compute_height_volume_functions() for op in ops): + raise ValueError( + "auto_surface_following_distance requires containers with height<->volume functions." + ) + current_volumes = [ + op.resource.compute_volume_from_height(liquid_heights[i]) for i, op in enumerate(ops) + ] + liquid_height_after = [ + op.resource.compute_height_from_volume(current_volumes[i] - op.volume) + for i, op in enumerate(ops) + ] + surface_following_distance = [ + liquid_heights[i] - liquid_height_after[i] for i in range(n) + ] + + liquid_surfaces_no_lld = backend_params.liquid_surface_no_lld or [ + wb + lh for wb, lh in zip(well_bottoms, liquid_heights) + ] + + # Check surface following distance doesn't go below minimum height (when LLD is off). + if any( + (well_bottoms[i] + liquid_heights[i] - surface_following_distance[i] - minimum_height[i] < -1e-6) + and lld_mode[i] == LLDMode.OFF + for i in range(n) + ): + raise ValueError( + f"surface_following_distance would result in a height below minimum_height. " + f"Well bottom: {well_bottoms}, liquid height: {liquid_heights}, " + f"surface_following_distance: {surface_following_distance}, minimum_height: {minimum_height}" + ) + + minimum_traverse_height_at_beginning_of_a_command = round( + (traverse_height_override or _DEFAULT_TRAVERSAL_HEIGHT) * 10 + ) + min_z_endpos = round( + (backend_params.min_z_endpos or _DEFAULT_TRAVERSAL_HEIGHT) * 10 + ) + + # Range validation (matches legacy aspirate_pip assertions, firmware units = real * 10). + aspiration_types = _fill(backend_params.aspiration_type, [0] * n) + _assert_range(aspiration_types, 0, 2, "aspiration_type") + _assert_range(x_positions, 0, 25000, "x_positions") + _assert_range(y_positions, 0, 6500, "y_positions") + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + assert 0 <= min_z_endpos <= 3600 + _assert_range([round(v * 10) for v in lld_search_height], 0, 3600, "lld_search_height") + _assert_range([round(v * 10) for v in clot_detection_height], 0, 500, "clot_detection_height") + _assert_range([round(v * 10) for v in liquid_surfaces_no_lld], 0, 3600, "liquid_surface_no_lld") + _assert_range([round(v * 10) for v in pull_out_distance_transport_air], 0, 3600, "pull_out_distance_transport_air") + _assert_range([round(v * 10) for v in second_section_height], 0, 3600, "second_section_height") + _assert_range([round(v * 10) for v in second_section_ratio], 0, 10000, "second_section_ratio") + _assert_range([round(v * 10) for v in minimum_height], 0, 3600, "minimum_height") + _assert_range([round(v * 10) for v in immersion_depth], 0, 3600, "immersion_depth") + _assert_range(immersion_depth_direction, 0, 1, "immersion_depth_direction") + _assert_range([round(v * 10) for v in surface_following_distance], 0, 3600, "surface_following_distance") + _assert_range([round(v * 10) for v in volumes], 0, 12500, "aspiration_volumes") + _assert_range([round(v * 10) for v in flow_rates], 4, 5000, "aspiration_speed") + _assert_range([round(v * 10) for v in transport_air_volume], 0, 500, "transport_air_volume") + _assert_range([round(v * 10) for v in blow_out_air_volumes], 0, 9999, "blow_out_air_volume") + _assert_range([round(v * 10) for v in pre_wetting_volume], 0, 999, "pre_wetting_volume") + _assert_range([m.value for m in lld_mode], 0, 4, "lld_mode") + _assert_range(gamma_lld_sensitivity, 1, 4, "gamma_lld_sensitivity") + _assert_range(dp_lld_sensitivity, 1, 4, "dp_lld_sensitivity") + _assert_range([round(v * 10) for v in aspirate_position_above_z_touch_off], 0, 100, "aspirate_position_above_z_touch_off") + _assert_range([round(v * 10) for v in detection_height_difference_for_dual_lld], 0, 99, "detection_height_difference_for_dual_lld") + _assert_range([round(v * 10) for v in swap_speed], 3, 1600, "swap_speed") + _assert_range([round(v * 10) for v in settling_time], 0, 99, "settling_time") + _assert_range([round(v * 10) for v in mix_volume], 0, 12500, "mix_volume") + _assert_range(mix_cycles, 0, 99, "mix_cycles") + _assert_range([round(v * 10) for v in mix_position_from_liquid_surface], 0, 900, "mix_position_from_liquid_surface") + _assert_range([round(v * 10) for v in mix_speed], 4, 5000, "mix_speed") + _assert_range([round(v * 10) for v in mix_surface_following_distance], 0, 3600, "mix_surface_following_distance") + _assert_range(limit_curve_index, 0, 999, "limit_curve_index") + assert 0 <= backend_params.recording_mode <= 2, "recording_mode must be between 0 and 2" + # 2nd section aspiration range checks + _assert_range([round(v * 10) for v in _fill( + backend_params.retract_height_over_2nd_section_to_empty_tip, [0.0] * n)], 0, 3600, + "retract_height_over_2nd_section_to_empty_tip") + _assert_range([round(v * 10) for v in _fill( + backend_params.dispensation_speed_during_emptying_tip, [50.0] * n)], 4, 5000, + "dispensation_speed_during_emptying_tip") + _assert_range([round(v * 10) for v in _fill( + backend_params.dosing_drive_speed_during_2nd_section_search, [50.0] * n)], 4, 5000, + "dosing_drive_speed_during_2nd_section_search") + _assert_range([round(v * 10) for v in _fill( + backend_params.z_drive_speed_during_2nd_section_search, [30.0] * n)], 3, 1600, + "z_drive_speed_during_2nd_section_search") + _assert_range([round(v * 10) for v in _fill( + backend_params.cup_upper_edge, [0.0] * n)], 0, 3600, "cup_upper_edge") + + try: + await self._driver.send_command( + module="C0", + command="AS", + tip_pattern=channels_involved, + read_timeout=max(300, self._driver.read_timeout), + at=[f"{at:01}" for at in aspiration_types], + tm=channels_involved, + xp=[f"{xp:05}" for xp in x_positions], + yp=[f"{yp:04}" for yp in y_positions], + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{min_z_endpos:04}", + lp=[f"{round(lsh * 10):04}" for lsh in lld_search_height], + ch=[f"{round(cd * 10):03}" for cd in clot_detection_height], + zl=[f"{round(ls * 10):04}" for ls in liquid_surfaces_no_lld], + po=[f"{round(po * 10):04}" for po in pull_out_distance_transport_air], + zu=[f"{round(sh * 10):04}" for sh in second_section_height], + zr=[f"{round(sr * 10):05}" for sr in second_section_ratio], + zx=[f"{round(mh * 10):04}" for mh in minimum_height], + ip=[f"{round(id_ * 10):04}" for id_ in immersion_depth], + it=[f"{idd}" for idd in immersion_depth_direction], + fp=[f"{round(sfd * 10):04}" for sfd in surface_following_distance], + av=[f"{round(vol * 10):05}" for vol in volumes], + as_=[f"{round(fr * 10):04}" for fr in flow_rates], + ta=[f"{round(tav * 10):03}" for tav in transport_air_volume], + ba=[f"{round(boa * 10):04}" for boa in blow_out_air_volumes], + oa=[f"{round(pwv * 10):03}" for pwv in pre_wetting_volume], + lm=[f"{mode.value}" for mode in lld_mode], + ll=[f"{s}" for s in gamma_lld_sensitivity], + lv=[f"{s}" for s in dp_lld_sensitivity], + zo=[f"{round(ap * 10):03}" for ap in aspirate_position_above_z_touch_off], + ld=[f"{round(dh * 10):02}" for dh in detection_height_difference_for_dual_lld], + de=[f"{round(ss * 10):04}" for ss in swap_speed], + wt=[f"{round(st * 10):02}" for st in settling_time], + mv=[f"{round(v * 10):05}" for v in mix_volume], + mc=[f"{c:02}" for c in mix_cycles], + mp=[f"{round(p * 10):03}" for p in mix_position_from_liquid_surface], + ms=[f"{round(s * 10):04}" for s in mix_speed], + mh=[f"{round(d * 10):04}" for d in mix_surface_following_distance], + gi=[f"{i:03}" for i in limit_curve_index], + gj=backend_params.tadm_algorithm, + gk=backend_params.recording_mode, + lk=[1 if x else 0 for x in _fill(backend_params.use_2nd_section_aspiration, [False] * n)], + ik=[f"{round(x * 10):04}" for x in _fill( + backend_params.retract_height_over_2nd_section_to_empty_tip, [0.0] * n)], + sd=[f"{round(x * 10):04}" for x in _fill( + backend_params.dispensation_speed_during_emptying_tip, [50.0] * n)], + se=[f"{round(x * 10):04}" for x in _fill( + backend_params.dosing_drive_speed_during_2nd_section_search, [50.0] * n)], + sz=[f"{round(x * 10):04}" for x in _fill( + backend_params.z_drive_speed_during_2nd_section_search, [30.0] * n)], + io=[f"{round(x * 10):04}" for x in _fill(backend_params.cup_upper_edge, [0.0] * n)], + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise + + # -- dispense --------------------------------------------------------------- + + @dataclass + class DispenseParams(BackendParams): + """STAR-specific parameters for ``dispense``.""" + hamilton_liquid_classes: Optional[List[Optional[HamiltonLiquidClass]]] = None + disable_volume_correction: Optional[List[bool]] = None + jet: Optional[List[bool]] = None + blow_out: Optional[List[bool]] = None + empty: Optional[List[bool]] = None + lld_search_height: Optional[List[float]] = None + liquid_surface_no_lld: Optional[List[float]] = None + pull_out_distance_transport_air: Optional[List[float]] = None + second_section_height: Optional[List[float]] = None + second_section_ratio: Optional[List[float]] = None + minimum_height: Optional[List[float]] = None + immersion_depth: Optional[List[float]] = None + immersion_depth_direction: Optional[List[int]] = None + surface_following_distance: Optional[List[float]] = None + cut_off_speed: Optional[List[float]] = None + stop_back_volume: Optional[List[float]] = None + transport_air_volume: Optional[List[float]] = None + lld_mode: Optional[List[LLDMode]] = None + side_touch_off_distance: float = 0.0 + dispense_position_above_z_touch_off: Optional[List[float]] = None + gamma_lld_sensitivity: Optional[List[int]] = None + dp_lld_sensitivity: Optional[List[int]] = None + swap_speed: Optional[List[float]] = None + settling_time: Optional[List[float]] = None + mix_position_from_liquid_surface: Optional[List[float]] = None + mix_surface_following_distance: Optional[List[float]] = None + limit_curve_index: Optional[List[int]] = None + minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None + min_z_endpos: Optional[float] = None + tadm_algorithm: bool = False + recording_mode: int = 0 + probe_liquid_height: bool = False + auto_surface_following_distance: bool = False + + async def dispense( + self, + ops: List[Dispense], + use_channels: List[int], + backend_params: Optional[BackendParams] = None, + ): + if not isinstance(backend_params, STARPIPBackend.DispenseParams): + backend_params = STARPIPBackend.DispenseParams() + + await self._ensure_iswap_parked() + self._ensure_can_reach_position(use_channels, ops, "dispense") + + x_positions, y_positions, channels_involved = _ops_to_fw_positions( + ops, use_channels, self.num_channels + ) + + n = len(ops) + + # Dispensing mode. + jet = backend_params.jet or [False] * n + blow_out = backend_params.blow_out or [False] * n + empty = backend_params.empty or [False] * n + dispensing_modes = [ + _dispensing_mode_for_op(empty=empty[i], jet=jet[i], blow_out=blow_out[i]) + for i in range(n) + ] + + # Resolve liquid classes. + hlcs = _resolve_liquid_classes(backend_params.hamilton_liquid_classes, ops, + jet=jet, blow_out=blow_out, is_aspirate=False) + + # Well bottoms. + well_bottoms = [ + op.resource.get_absolute_location(x="c", y="c", z="b").z + + op.offset.z + + op.resource.material_z_thickness + for op in ops + ] + + # LLD search height. + if backend_params.lld_search_height is None: + lld_search_height = [ + wb + op.resource.get_absolute_size_z() + (2.7 if isinstance(op.resource, Well) else 5) + for wb, op in zip(well_bottoms, ops) + ] + else: + lld_search_height = [ + wb + sh for wb, sh in zip(well_bottoms, backend_params.lld_search_height) + ] + + pull_out_distance_transport_air = _fill( + backend_params.pull_out_distance_transport_air, [10.0] * n + ) + second_section_height = _fill(backend_params.second_section_height, [3.2] * n) + second_section_ratio = _fill(backend_params.second_section_ratio, [618.0] * n) + minimum_height = _fill(backend_params.minimum_height, well_bottoms) + + immersion_depth_raw = backend_params.immersion_depth or [0.0] * n + immersion_depth_direction = backend_params.immersion_depth_direction or [ + 0 if id_ >= 0 else 1 for id_ in immersion_depth_raw + ] + immersion_depth = [ + im * (-1 if immersion_depth_direction[i] else 1) + for i, im in enumerate(immersion_depth_raw) + ] + + surface_following_distance = _fill(backend_params.surface_following_distance, [0.0] * n) + + # Volumes (with liquid class correction). + disable_vc = _fill(backend_params.disable_volume_correction, [False] * n) + volumes = [ + hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume + for op, hlc, disabled in zip(ops, hlcs, disable_vc) + ] + + # Flow rates (liquid class default). + flow_rates = [ + op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 120.0) + for op, hlc in zip(ops, hlcs) + ] + + cut_off_speed = _fill(backend_params.cut_off_speed, [5.0] * n) + stop_back_volume = _fill(backend_params.stop_back_volume, + [hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hlcs]) + transport_air_volume = _fill(backend_params.transport_air_volume, + [hlc.dispense_air_transport_volume if hlc is not None else 0.0 for hlc in hlcs]) + blow_out_air_volumes = [ + op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0.0) + for op, hlc in zip(ops, hlcs) + ] + + lld_mode = _fill(backend_params.lld_mode, [LLDMode.OFF] * n) + dispense_position_above_z_touch_off = _fill( + backend_params.dispense_position_above_z_touch_off, [0.0] * n + ) + gamma_lld_sensitivity = _fill(backend_params.gamma_lld_sensitivity, [1] * n) + dp_lld_sensitivity = _fill(backend_params.dp_lld_sensitivity, [1] * n) + swap_speed = _fill(backend_params.swap_speed, + [hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hlcs]) + settling_time = _fill(backend_params.settling_time, + [hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hlcs]) + + # Mix. + mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] + mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] + mix_position_from_liquid_surface = _fill( + backend_params.mix_position_from_liquid_surface, [0.0] * n + ) + mix_speed = [op.mix.flow_rate if op.mix is not None else 1.0 for op in ops] + mix_surface_following_distance = _fill( + backend_params.mix_surface_following_distance, [0.0] * n + ) + limit_curve_index = _fill(backend_params.limit_curve_index, [0] * n) + + side_touch_off_distance = round(backend_params.side_touch_off_distance * 10) + + # Probe liquid height if requested. + traverse_height_override = backend_params.minimum_traverse_height_at_beginning_of_a_command + if backend_params.probe_liquid_height: + if any(op.liquid_height is not None for op in ops): + raise ValueError("Cannot use probe_liquid_height when liquid heights are set.") + liquid_heights = await self._driver.probe_liquid_heights( + containers=[op.resource for op in ops], + use_channels=use_channels, + resource_offsets=[op.offset for op in ops], + move_to_z_safety_after=False, + ) + logger.info("Detected liquid heights: %s", liquid_heights) + traverse_height_override = 100.0 + else: + liquid_heights = [op.liquid_height or 0.0 for op in ops] + + # Auto surface following distance. + if backend_params.auto_surface_following_distance: + if any(op.liquid_height is None for op in ops) and not backend_params.probe_liquid_height: + raise ValueError( + "To use auto_surface_following_distance all liquid heights must be set or " + "probe_liquid_height must be True." + ) + if any(not op.resource.supports_compute_height_volume_functions() for op in ops): + raise ValueError( + "auto_surface_following_distance requires containers with height<->volume functions." + ) + current_volumes = [ + op.resource.compute_volume_from_height(liquid_heights[i]) for i, op in enumerate(ops) + ] + liquid_height_after = [ + op.resource.compute_height_from_volume(current_volumes[i] + op.volume) + for i, op in enumerate(ops) + ] + surface_following_distance = [ + liquid_height_after[i] - liquid_heights[i] for i in range(n) + ] + + liquid_surfaces_no_lld = backend_params.liquid_surface_no_lld or [ + wb + lh for wb, lh in zip(well_bottoms, liquid_heights) + ] + + minimum_traverse_height_at_beginning_of_a_command = round( + (traverse_height_override or _DEFAULT_TRAVERSAL_HEIGHT) * 10 + ) + min_z_endpos = round( + (backend_params.min_z_endpos or _DEFAULT_TRAVERSAL_HEIGHT) * 10 + ) + + # Range validation (matches legacy dispense_pip assertions, firmware units = real * 10). + _assert_range(dispensing_modes, 0, 4, "dispensing_mode") + _assert_range(x_positions, 0, 25000, "x_positions") + _assert_range(y_positions, 0, 6500, "y_positions") + _assert_range([round(v * 10) for v in minimum_height], 0, 3600, "minimum_height") + _assert_range([round(v * 10) for v in lld_search_height], 0, 3600, "lld_search_height") + _assert_range([round(v * 10) for v in liquid_surfaces_no_lld], 0, 3600, "liquid_surface_no_lld") + _assert_range([round(v * 10) for v in pull_out_distance_transport_air], 0, 3600, "pull_out_distance_transport_air") + _assert_range([round(v * 10) for v in immersion_depth], 0, 3600, "immersion_depth") + _assert_range(immersion_depth_direction, 0, 1, "immersion_depth_direction") + _assert_range([round(v * 10) for v in surface_following_distance], 0, 3600, "surface_following_distance") + _assert_range([round(v * 10) for v in second_section_height], 0, 3600, "second_section_height") + _assert_range([round(v * 10) for v in second_section_ratio], 0, 10000, "second_section_ratio") + assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + assert 0 <= min_z_endpos <= 3600 + _assert_range([round(v * 10) for v in volumes], 0, 12500, "dispense_volumes") + _assert_range([round(v * 10) for v in flow_rates], 4, 5000, "dispense_speed") + _assert_range([round(v * 10) for v in cut_off_speed], 4, 5000, "cut_off_speed") + _assert_range([round(v * 10) for v in stop_back_volume], 0, 180, "stop_back_volume") + _assert_range([round(v * 10) for v in transport_air_volume], 0, 500, "transport_air_volume") + _assert_range([round(v * 10) for v in blow_out_air_volumes], 0, 9999, "blow_out_air_volume") + _assert_range([m.value for m in lld_mode], 0, 4, "lld_mode") + assert 0 <= side_touch_off_distance <= 45, "side_touch_off_distance must be between 0 and 45" + _assert_range([round(v * 10) for v in dispense_position_above_z_touch_off], 0, 100, "dispense_position_above_z_touch_off") + _assert_range(gamma_lld_sensitivity, 1, 4, "gamma_lld_sensitivity") + _assert_range(dp_lld_sensitivity, 1, 4, "dp_lld_sensitivity") + _assert_range([round(v * 10) for v in swap_speed], 3, 1600, "swap_speed") + _assert_range([round(v * 10) for v in settling_time], 0, 99, "settling_time") + _assert_range([round(v * 10) for v in mix_volume], 0, 12500, "mix_volume") + _assert_range(mix_cycles, 0, 99, "mix_cycles") + _assert_range([round(v * 10) for v in mix_position_from_liquid_surface], 0, 900, "mix_position_from_liquid_surface") + _assert_range([round(v * 10) for v in mix_speed], 4, 5000, "mix_speed") + _assert_range([round(v * 10) for v in mix_surface_following_distance], 0, 3600, "mix_surface_following_distance") + _assert_range(limit_curve_index, 0, 999, "limit_curve_index") + assert 0 <= backend_params.recording_mode <= 2, "recording_mode must be between 0 and 2" + + try: + await self._driver.send_command( + module="C0", + command="DS", + tip_pattern=channels_involved, + read_timeout=max(300, self._driver.read_timeout), + dm=[f"{dm:01}" for dm in dispensing_modes], + tm=[f"{int(t):01}" for t in channels_involved], + xp=[f"{xp:05}" for xp in x_positions], + yp=[f"{yp:04}" for yp in y_positions], + zx=[f"{round(mh * 10):04}" for mh in minimum_height], + lp=[f"{round(lsh * 10):04}" for lsh in lld_search_height], + zl=[f"{round(ls * 10):04}" for ls in liquid_surfaces_no_lld], + po=[f"{round(po * 10):04}" for po in pull_out_distance_transport_air], + ip=[f"{round(id_ * 10):04}" for id_ in immersion_depth], + it=[f"{idd:01}" for idd in immersion_depth_direction], + fp=[f"{round(sfd * 10):04}" for sfd in surface_following_distance], + zu=[f"{round(sh * 10):04}" for sh in second_section_height], + zr=[f"{round(sr * 10):05}" for sr in second_section_ratio], + th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", + te=f"{min_z_endpos:04}", + dv=[f"{round(vol * 10):05}" for vol in volumes], + ds=[f"{round(fr * 10):04}" for fr in flow_rates], + ss=[f"{round(cs * 10):04}" for cs in cut_off_speed], + rv=[f"{round(sbv * 10):03}" for sbv in stop_back_volume], + ta=[f"{round(tav * 10):03}" for tav in transport_air_volume], + ba=[f"{round(boa * 10):04}" for boa in blow_out_air_volumes], + lm=[f"{mode.value:01}" for mode in lld_mode], + dj=f"{side_touch_off_distance:02}", + zo=[f"{round(dp * 10):03}" for dp in dispense_position_above_z_touch_off], + ll=[f"{s:01}" for s in gamma_lld_sensitivity], + lv=[f"{s:01}" for s in dp_lld_sensitivity], + de=[f"{round(ss * 10):04}" for ss in swap_speed], + wt=[f"{round(st * 10):02}" for st in settling_time], + mv=[f"{round(v * 10):05}" for v in mix_volume], + mc=[f"{c:02}" for c in mix_cycles], + mp=[f"{round(p * 10):03}" for p in mix_position_from_liquid_surface], + ms=[f"{round(s * 10):04}" for s in mix_speed], + mh=[f"{round(d * 10):04}" for d in mix_surface_following_distance], + gi=[f"{i:03}" for i in limit_curve_index], + gj=backend_params.tadm_algorithm, + gk=backend_params.recording_mode, + ) + except STARFirmwareError as e: + if plr_e := convert_star_firmware_error_to_plr_error(e): + raise plr_e from e + raise + + # -- can_pick_up_tip -------------------------------------------------------- + + def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: + if not isinstance(tip, HamiltonTip): + return False + if tip.tip_size in {TipSize.XL}: + return False + return True diff --git a/pylabrobot/hamilton/liquid_handlers/star/star.py b/pylabrobot/hamilton/liquid_handlers/star/star.py new file mode 100644 index 00000000000..87e15733584 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/star.py @@ -0,0 +1,121 @@ +"""STAR device: wires STARDriver backends to PIP/Head96/iSWAP capability frontends.""" + +from contextlib import asynccontextmanager +from typing import AsyncIterator, Optional + +from pylabrobot.arms.arm import GripperArm +from pylabrobot.arms.orientable_arm import OrientableArm +from pylabrobot.capabilities.liquid_handling.head96 import Head96Capability +from pylabrobot.capabilities.liquid_handling.pip import PIP +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate +from pylabrobot.resources.hamilton import HamiltonDeck +from pylabrobot.resources.hamilton.hamilton_decks import HamiltonCoreGrippers + +from .chatterbox import STARChatterboxDriver +from .core import CoreGripper +from .driver import STARDriver + + +class STAR(Device): + """Hamilton STAR liquid handler. + + User-facing device that wires capability frontends (PIP, Head96, iSWAP) to the + STARDriver's backends after hardware discovery during setup(). + """ + + def __init__(self, deck: HamiltonDeck, chatterbox: bool = False): + driver = STARChatterboxDriver() if chatterbox else STARDriver() + super().__init__(driver=driver) + self._driver: STARDriver = driver + self.deck = deck + self.pip: PIP # set in setup() + self.head96: Optional[Head96Capability] = None # set in setup() if installed + self.iswap: Optional[OrientableArm] = None # set in setup() if installed + + async def setup(self): + await self._driver.setup() + + # PIP is always present. + self.pip = PIP(backend=self._driver.pip) + self._capabilities = [self.pip] + + # Head96 only if the hardware has a 96-head installed. + if self._driver.head96 is not None: + self.head96 = Head96Capability(backend=self._driver.head96) + self._capabilities.append(self.head96) + + # iSWAP only if installed. + if self._driver.iswap is not None: + self.iswap = OrientableArm(backend=self._driver.iswap, reference_resource=self.deck) + self._capabilities.append(self.iswap) + + for cap in self._capabilities: + await cap._on_setup() + self._setup_finished = True + + async def stop(self): + for cap in reversed(self._capabilities): + await cap._on_stop() + await self._driver.stop() + self._setup_finished = False + self.head96 = None + self.iswap = None + + # -- CoRe grippers --------------------------------------------------------- + + @asynccontextmanager + async def core_grippers( + self, + front_channel: int = 7, + front_offset: Coordinate = Coordinate.zero(), + back_offset: Coordinate = Coordinate.zero(), + traversal_height: float = 280.0, + ) -> AsyncIterator[GripperArm]: + """Context manager that picks up CoRe gripper tools on enter and returns them on exit. + + Usage:: + + async with star.core_grippers(front_channel=7) as arm: + await arm.move_resource(plate, destination) + """ + + # Park iSWAP first if it's out — the arms share the X drive. + if self.iswap is not None and not self.iswap.backend.parked: + await self.iswap.backend.park() + + core_grippers_resource = self.deck.get_resource("core_grippers") + assert isinstance(core_grippers_resource, HamiltonCoreGrippers) + + back_channel = front_channel - 1 + loc = core_grippers_resource.get_absolute_location() + xs = loc.x + front_offset.x + back_y = int(loc.y + core_grippers_resource.back_channel_y_center + back_offset.y) + front_y = int(loc.y + core_grippers_resource.front_channel_y_center + front_offset.y) + z_offset = front_offset.z + + await self._driver.pick_up_core_gripper_tools( + x_position=xs, + back_channel_y=back_y, + front_channel_y=front_y, + back_channel=back_channel, + front_channel=front_channel, + begin_z=235.0 + z_offset, + end_z=225.0 + z_offset, + traversal_height=traversal_height, + ) + + backend = CoreGripper(driver=self._driver) + arm = GripperArm(backend=backend, reference_resource=self.deck, grip_axis="y") + + try: + yield arm + finally: + await self._driver.return_core_gripper_tools( + x_position=xs, + back_channel_y=back_y, + front_channel_y=front_y, + begin_z=215.0 + z_offset, + end_z=205.0 + z_offset, + traversal_height=traversal_height, + ) diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/__init__.py b/pylabrobot/hamilton/liquid_handlers/star/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/hamilton/liquid_handlers/star/autoload_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/autoload_tests.py similarity index 71% rename from pylabrobot/hamilton/liquid_handlers/star/autoload_tests.py rename to pylabrobot/hamilton/liquid_handlers/star/tests/autoload_tests.py index 910a0eb6177..f774bc1c6af 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/autoload_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/autoload_tests.py @@ -276,3 +276,102 @@ async def test_load_carrier_from_belt_no_barcode(self): self.assertEqual(calls[0].kwargs["command"], "CL") self.assertEqual(calls[0].kwargs["bd"], "0") # vertical when no barcode self.assertEqual(calls[0].kwargs["cn"], "00") # no scanning + + async def test_load_carrier_from_belt_with_barcode(self): + """Test loading a carrier from the belt with barcode scanning enabled.""" + self.mock_driver.send_command.side_effect = [ + None, # CB command (set_1d_barcode_type) + "C0CLid0001bb/ABC123/DEF456/00/GHI789/JKL012", # CL command with barcodes + None, # safe z (park) + None, # park XP + ] + result = await self.autoload.load_carrier_from_belt( + barcode_reading=True, + barcode_reading_direction="horizontal", + barcode_symbology="Code 128 (Subset B and C)", + no_container_per_carrier=5, + park_autoload_after=True, + ) + # Verify set_1d_barcode_type was called (CB command) + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[0].kwargs["module"], "C0") + self.assertEqual(calls[0].kwargs["command"], "CB") + # Verify CL command + self.assertEqual(calls[1].kwargs["module"], "C0") + self.assertEqual(calls[1].kwargs["command"], "CL") + self.assertEqual(calls[1].kwargs["bd"], "1") # horizontal + # Verify returned barcode dict + self.assertEqual(len(result), 5) + self.assertIsNotNone(result[0]) + self.assertEqual(result[0].data, "ABC123") + self.assertIsNotNone(result[1]) + self.assertEqual(result[1].data, "DEF456") + self.assertIsNone(result[2]) # "00" means no barcode + self.assertIsNotNone(result[3]) + self.assertEqual(result[3].data, "GHI789") + self.assertIsNotNone(result[4]) + self.assertEqual(result[4].data, "JKL012") + + async def test_load_carrier(self): + """Test high-level load_carrier orchestration.""" + self.mock_driver.send_command.side_effect = [ + {"ct": 1}, # CT: presence check returns True + "C0CIid0001", # CI: carrier barcode scan (no barcode reading) + "C0CLid0001", # CL: belt load (no barcode reading) + None, # IV: safe z (park) + None, # XP: park + ] + result = await self.autoload.load_carrier( + carrier_end_rail=10, + barcode_reading=False, + carrier_barcode_reading=False, + ) + self.assertIsNone(result["carrier_barcode"]) + self.assertIsNone(result["container_barcodes"]) + # Verify CT was called for presence check + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[0].kwargs["command"], "CT") + # Verify CI was called + self.assertEqual(calls[1].kwargs["command"], "CI") + # Verify CL was called + self.assertEqual(calls[2].kwargs["command"], "CL") + + async def test_take_carrier_out_to_belt_error_recovery(self): + """Test that move_to_safe_z_position (IV) is called before RuntimeError on CN failure.""" + self.mock_driver.send_command.side_effect = [ + {"ct": 0}, # CT: carrier NOT present on tray + RuntimeError("CN firmware error"), # CN: raises exception + None, # IV: move_to_safe_z_position + ] + with self.assertRaises(RuntimeError): + await self.autoload.take_carrier_out_to_belt(carrier_end_rail=10) + # Verify move_to_safe_z_position (IV) was called after the CN error + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[2].kwargs["command"], "IV") + + async def test_unload_carrier_after_barcode_scanning_error_recovery(self): + """Test that move_to_safe_z_position (IV) is called before RuntimeError on CA failure.""" + self.mock_driver.send_command.side_effect = [ + RuntimeError("CA firmware error"), # CA: raises exception + None, # IV: move_to_safe_z_position + ] + with self.assertRaises(RuntimeError): + await self.autoload.unload_carrier_after_barcode_scanning() + # Verify move_to_safe_z_position (IV) was called + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[1].kwargs["command"], "IV") + + async def test_load_carrier_from_tray_and_scan_carrier_barcode_error_recovery(self): + """Test that move_to_safe_z_position (IV) is called before RuntimeError on CI failure.""" + self.mock_driver.send_command.side_effect = [ + RuntimeError("CI firmware error"), # CI: raises exception + None, # IV: move_to_safe_z_position + ] + with self.assertRaises(RuntimeError): + await self.autoload.load_carrier_from_tray_and_scan_carrier_barcode( + carrier_end_rail=10, + carrier_barcode_reading=True, + ) + # Verify move_to_safe_z_position (IV) was called + calls = self.mock_driver.send_command.call_args_list + self.assertEqual(calls[1].kwargs["command"], "IV") diff --git a/pylabrobot/hamilton/liquid_handlers/star/core_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/core_tests.py similarity index 85% rename from pylabrobot/hamilton/liquid_handlers/star/core_tests.py rename to pylabrobot/hamilton/liquid_handlers/star/tests/core_tests.py index 7ae9b818a0a..fd386a489a2 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/core_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/core_tests.py @@ -10,9 +10,9 @@ class TestCoreGripperCommands(unittest.IsolatedAsyncioTestCase): STARBackend equivalents.""" async def asyncSetUp(self): - self.mock_interface = MagicMock() - self.mock_interface.send_command = AsyncMock() - self.core = CoreGripper(interface=self.mock_interface) + self.mock_driver = MagicMock() + self.mock_driver.send_command = AsyncMock() + self.core = CoreGripper(driver=self.mock_driver) async def test_pick_up_at_location(self): """ZP with default params, plate width 86mm at (347.9, 114.2, 187.4).""" @@ -21,7 +21,7 @@ async def test_pick_up_at_location(self): resource_width=86.0, ) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="ZP", xs="03479", @@ -51,7 +51,7 @@ async def test_pick_up_at_location_custom_params(self): ), ) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="ZP", xs="05000", @@ -74,7 +74,7 @@ async def test_drop_at_location(self): resource_width=86.0, ) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="ZR", xs="03479", @@ -101,7 +101,7 @@ async def test_drop_at_location_custom_params(self): ), ) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="ZR", xs="05000", @@ -121,7 +121,7 @@ async def test_move_to_location(self): location=Coordinate(500.0, 200.0, 150.0), ) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="ZM", xs="05000", @@ -144,7 +144,7 @@ async def test_move_to_location_custom_params(self): ), ) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="ZM", xs="08000", @@ -160,7 +160,7 @@ async def test_open_gripper(self): """ZO command.""" await self.core.open_gripper(gripper_width=0) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="ZO", ) diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/cover_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/cover_tests.py new file mode 100644 index 00000000000..59dbd569693 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/cover_tests.py @@ -0,0 +1,68 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.hamilton.liquid_handlers.star.cover import STARCover + + +class TestSTARCoverCommands(unittest.IsolatedAsyncioTestCase): + """Test that STARCover methods produce the exact firmware commands expected.""" + + async def asyncSetUp(self): + self.mock_driver = MagicMock() + self.mock_driver.send_command = AsyncMock() + self.cover = STARCover(driver=self.mock_driver) + + async def test_lock(self): + await self.cover.lock() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CO") + + async def test_unlock(self): + await self.cover.unlock() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="HO") + + async def test_disable(self): + await self.cover.disable() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CD") + + async def test_enable(self): + await self.cover.enable() + self.mock_driver.send_command.assert_called_once_with(module="C0", command="CE") + + async def test_set_output(self): + await self.cover.set_output(output=1) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="OS", on=1) + + async def test_set_output_reserve(self): + await self.cover.set_output(output=2) + self.mock_driver.send_command.assert_called_once_with(module="C0", command="OS", on=2) + + async def test_set_output_invalid(self): + with self.assertRaises(AssertionError): + await self.cover.set_output(output=0) + with self.assertRaises(AssertionError): + await self.cover.set_output(output=4) + + async def test_reset_output(self): + await self.cover.reset_output(output=1) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="QS", on=1, fmt="#" + ) + + async def test_reset_output_invalid(self): + with self.assertRaises(AssertionError): + await self.cover.reset_output(output=0) + with self.assertRaises(AssertionError): + await self.cover.reset_output(output=4) + + async def test_is_open_true(self): + self.mock_driver.send_command.return_value = {"qc": 1} + result = await self.cover.is_open() + self.assertTrue(result) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="QC", fmt="qc#" + ) + + async def test_is_open_false(self): + self.mock_driver.send_command.return_value = {"qc": 0} + result = await self.cover.is_open() + self.assertFalse(result) diff --git a/pylabrobot/hamilton/liquid_handlers/star/iswap_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/iswap_tests.py similarity index 82% rename from pylabrobot/hamilton/liquid_handlers/star/iswap_tests.py rename to pylabrobot/hamilton/liquid_handlers/star/tests/iswap_tests.py index 8dd85f91f76..02b429eb646 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/iswap_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/iswap_tests.py @@ -10,9 +10,9 @@ class TestiSWAPCommands(unittest.IsolatedAsyncioTestCase): STARBackend equivalents.""" async def asyncSetUp(self): - self.mock_interface = MagicMock() - self.mock_interface.send_command = AsyncMock() - self.iswap = iSWAP(interface=self.mock_interface) + self.mock_driver = MagicMock() + self.mock_driver.send_command = AsyncMock() + self.iswap = iSWAP(driver=self.mock_driver) async def test_pick_up_at_location(self): """C0PPid0001xs03479xd0yj1142yd0zj1874zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0""" @@ -22,7 +22,7 @@ async def test_pick_up_at_location(self): resource_width=127.76, ) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="PP", xs="03479", @@ -50,7 +50,7 @@ async def test_pick_up_grip_direction_left(self): resource_width=127.76, ) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="PP", xs="10427", @@ -78,7 +78,7 @@ async def test_drop_at_location(self): resource_width=127.76, ) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="PR", xs="03479", @@ -103,7 +103,7 @@ async def test_drop_grip_direction_left(self): resource_width=127.76, ) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="PR", xs="10427", @@ -123,17 +123,17 @@ async def test_drop_grip_direction_left(self): async def test_park(self): await self.iswap.park() - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="PG", - th=2840, + th=2800, ) self.assertTrue(self.iswap.parked) async def test_park_custom_height(self): await self.iswap.park(backend_params=iSWAP.ParkParams(minimum_traverse_height=200.0)) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="PG", th=2000, @@ -142,7 +142,7 @@ async def test_park_custom_height(self): async def test_open_gripper(self): await self.iswap.open_gripper(gripper_width=130.8) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="GF", go="1308", @@ -154,7 +154,7 @@ async def test_close_gripper(self): backend_params=iSWAP.CloseGripperParams(grip_strength=5, plate_width_tolerance=0), ) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="GC", gw=5, @@ -163,17 +163,17 @@ async def test_close_gripper(self): ) async def test_is_gripper_closed(self): - self.mock_interface.send_command.return_value = {"ph": 1} + self.mock_driver.send_command.return_value = {"ph": 1} result = await self.iswap.is_gripper_closed() self.assertTrue(result) - self.mock_interface.send_command.assert_called_once_with( + self.mock_driver.send_command.assert_called_once_with( module="C0", command="QP", fmt="ph#", ) async def test_is_gripper_open(self): - self.mock_interface.send_command.return_value = {"ph": 0} + self.mock_driver.send_command.return_value = {"ph": 0} result = await self.iswap.is_gripper_closed() self.assertFalse(result) diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/legacy_parity_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/legacy_parity_tests.py new file mode 100644 index 00000000000..0b69e309a6b --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/legacy_parity_tests.py @@ -0,0 +1,191 @@ +"""Tests that verify new backends produce the same firmware commands as legacy. + +Sets up identical decks, runs operations through both, compares the firmware command strings. +""" + +import unittest +from unittest.mock import AsyncMock, MagicMock, call + +from pylabrobot.legacy.liquid_handling import LiquidHandler +from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_chatterbox import STARChatterboxBackend +from pylabrobot.resources import ( + TIP_CAR_480_A00, + PLT_CAR_L5AC_A00, + Cor_96_wellplate_360ul_Fb, +) +from pylabrobot.resources.hamilton import STARLetDeck, hamilton_96_tiprack_1000uL_filter + +from pylabrobot.hamilton.liquid_handlers.star.chatterbox import STARChatterboxDriver +from pylabrobot.hamilton.liquid_handlers.star.star import STAR +from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense, Pickup, TipDrop + + +class _CaptureDriver(STARChatterboxDriver): + """Captures firmware commands instead of printing them.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.commands = [] + + async def send_command(self, module, command, auto_id=True, tip_pattern=None, + write_timeout=None, read_timeout=None, wait=True, + fmt=None, **kwargs): + cmd, _ = self._assemble_command(module=module, command=command, + auto_id=auto_id, tip_pattern=tip_pattern, **kwargs) + self.commands.append(cmd) + return None + + +class TestLegacyParity(unittest.IsolatedAsyncioTestCase): + + async def asyncSetUp(self): + # --- Legacy setup --- + self.legacy_backend = STARChatterboxBackend() + self.legacy_backend._write_and_read_command = AsyncMock(return_value=None) + self.legacy_deck = STARLetDeck() + + tip_car = TIP_CAR_480_A00(name="tip_carrier") + tip_car[0] = hamilton_96_tiprack_1000uL_filter(name="tips_01") + self.legacy_deck.assign_child_resource(tip_car, rails=3) + + plt_car = PLT_CAR_L5AC_A00(name="plate_carrier") + plt_car[0] = Cor_96_wellplate_360ul_Fb(name="plate_01") + self.legacy_deck.assign_child_resource(plt_car, rails=15) + + self.lh = LiquidHandler(self.legacy_backend, deck=self.legacy_deck) + await self.lh.setup() + + # --- New setup --- + self.new_driver = _CaptureDriver() + self.new_deck = STARLetDeck() + + tip_car2 = TIP_CAR_480_A00(name="tip_carrier") + tip_car2[0] = hamilton_96_tiprack_1000uL_filter(name="tips_01") + self.new_deck.assign_child_resource(tip_car2, rails=3) + + plt_car2 = PLT_CAR_L5AC_A00(name="plate_carrier") + plt_car2[0] = Cor_96_wellplate_360ul_Fb(name="plate_01") + self.new_deck.assign_child_resource(plt_car2, rails=15) + + self.star = STAR(deck=self.new_deck, chatterbox=True) + # Replace the driver with our capture driver + self.star._driver = self.new_driver + await self.star._driver.setup() + from .pip_backend import STARPIPBackend + self.star._driver.pip = STARPIPBackend(self.new_driver) + from .head96_backend import STARHead96Backend + self.star._driver.head96 = STARHead96Backend(self.new_driver) + from pylabrobot.capabilities.liquid_handling.pip import PIP + from pylabrobot.capabilities.liquid_handling.head96 import Head96Capability + self.star.pip = PIP(backend=self.star._driver.pip) + self.star.head96 = Head96Capability(backend=self.star._driver.head96) + self.star._capabilities = [self.star.pip, self.star.head96] + for cap in self.star._capabilities: + await cap._on_setup() + self.star._setup_finished = True + + def _get_legacy_commands(self): + """Extract firmware command strings from legacy mock calls.""" + commands = [] + for c in self.legacy_backend._write_and_read_command.call_args_list: + cmd = c.kwargs.get("cmd") or c.args[1] + commands.append(cmd) + return commands + + def _assert_commands_match(self, legacy_cmds, new_cmds, label=""): + self.assertEqual(len(legacy_cmds), len(new_cmds), + f"{label} command count mismatch: legacy={len(legacy_cmds)}, new={len(new_cmds)}\n" + f"legacy: {legacy_cmds}\nnew: {new_cmds}") + for i, (leg, new) in enumerate(zip(legacy_cmds, new_cmds)): + # Strip the id (id####, 6 chars at position 4-9) since counters won't match + leg_no_id = leg[:4] + leg[10:] + new_no_id = new[:4] + new[10:] + self.assertEqual(leg_no_id, new_no_id, + f"{label} command {i} mismatch:\nlegacy: {leg}\nnew: {new}") + + async def test_pick_up_tips(self): + tiprack_legacy = self.legacy_deck.get_resource("tips_01") + tiprack_new = self.new_deck.get_resource("tips_01") + + self.legacy_backend._write_and_read_command.reset_mock() + await self.lh.pick_up_tips(tiprack_legacy["A1:C1"]) + legacy_cmds = self._get_legacy_commands() + + self.new_driver.commands.clear() + await self.star.pip.pick_up_tips(tiprack_new["A1:C1"]) + new_cmds = self.new_driver.commands + + self._assert_commands_match(legacy_cmds, new_cmds, "pick_up_tips") + + async def test_aspirate(self): + tiprack_legacy = self.legacy_deck.get_resource("tips_01") + tiprack_new = self.new_deck.get_resource("tips_01") + plate_legacy = self.legacy_deck.get_resource("plate_01") + plate_new = self.new_deck.get_resource("plate_01") + + # Pick up tips first (both sides) + await self.lh.pick_up_tips(tiprack_legacy["A1:C1"]) + await self.star.pip.pick_up_tips(tiprack_new["A1:C1"]) + + # Set volume so legacy doesn't complain + for well in plate_legacy.get_items(["A1", "B1", "C1"]): + well.tracker.set_volume(200) + for well in plate_new.get_items(["A1", "B1", "C1"]): + well.tracker.set_volume(200) + + # Aspirate + self.legacy_backend._write_and_read_command.reset_mock() + await self.lh.aspirate(plate_legacy["A1:C1"], vols=[100.0, 50.0, 200.0]) + legacy_cmds = self._get_legacy_commands() + + self.new_driver.commands.clear() + await self.star.pip.aspirate(plate_new["A1:C1"], vols=[100.0, 50.0, 200.0]) + new_cmds = self.new_driver.commands + + self._assert_commands_match(legacy_cmds, new_cmds, "aspirate") + + async def test_dispense(self): + tiprack_legacy = self.legacy_deck.get_resource("tips_01") + tiprack_new = self.new_deck.get_resource("tips_01") + plate_legacy = self.legacy_deck.get_resource("plate_01") + plate_new = self.new_deck.get_resource("plate_01") + + # Pick up tips + aspirate first + await self.lh.pick_up_tips(tiprack_legacy["A1:C1"]) + await self.star.pip.pick_up_tips(tiprack_new["A1:C1"]) + for well in plate_legacy.get_items(["A1", "B1", "C1"]): + well.tracker.set_volume(200) + for well in plate_new.get_items(["A1", "B1", "C1"]): + well.tracker.set_volume(200) + await self.lh.aspirate(plate_legacy["A1:C1"], vols=[100.0, 50.0, 200.0]) + await self.star.pip.aspirate(plate_new["A1:C1"], vols=[100.0, 50.0, 200.0]) + + # Dispense + self.legacy_backend._write_and_read_command.reset_mock() + await self.lh.dispense(plate_legacy["D1:F1"], vols=[100.0, 50.0, 200.0]) + legacy_cmds = self._get_legacy_commands() + + self.new_driver.commands.clear() + await self.star.pip.dispense(plate_new["D1:F1"], vols=[100.0, 50.0, 200.0]) + new_cmds = self.new_driver.commands + + self._assert_commands_match(legacy_cmds, new_cmds, "dispense") + + async def test_drop_tips(self): + tiprack_legacy = self.legacy_deck.get_resource("tips_01") + tiprack_new = self.new_deck.get_resource("tips_01") + + # Pick up tips first + await self.lh.pick_up_tips(tiprack_legacy["A1:C1"]) + await self.star.pip.pick_up_tips(tiprack_new["A1:C1"]) + + # Drop tips + self.legacy_backend._write_and_read_command.reset_mock() + await self.lh.drop_tips(tiprack_legacy["A1:C1"]) + legacy_cmds = self._get_legacy_commands() + + self.new_driver.commands.clear() + await self.star.pip.drop_tips(tiprack_new["A1:C1"]) + new_cmds = self.new_driver.commands + + self._assert_commands_match(legacy_cmds, new_cmds, "drop_tips") diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/wash_station_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/wash_station_tests.py new file mode 100644 index 00000000000..205cf78e1bd --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/wash_station_tests.py @@ -0,0 +1,210 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.hamilton.liquid_handlers.star.wash_station import STARWashStation + + +class TestSTARWashStationCommands(unittest.IsolatedAsyncioTestCase): + """Test that STARWashStation methods produce the exact firmware commands expected.""" + + async def asyncSetUp(self): + self.mock_driver = MagicMock() + self.mock_driver.send_command = AsyncMock() + self.ws = STARWashStation(driver=self.mock_driver) + + # -- request_settings ------------------------------------------------------- + + async def test_request_settings_station_1(self): + self.mock_driver.send_command.return_value = {"et": 4} + result = await self.ws.request_settings(station=1) + self.assertEqual(result, 4) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="ET", fmt="et#", ep=1 + ) + + async def test_request_settings_station_2(self): + self.mock_driver.send_command.return_value = {"et": 0} + result = await self.ws.request_settings(station=2) + self.assertEqual(result, 0) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="ET", fmt="et#", ep=2 + ) + + async def test_request_settings_station_3(self): + self.mock_driver.send_command.return_value = {"et": 5} + result = await self.ws.request_settings(station=3) + self.assertEqual(result, 5) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="ET", fmt="et#", ep=3 + ) + + async def test_request_settings_invalid_station_0(self): + with self.assertRaises(AssertionError): + await self.ws.request_settings(station=0) + + async def test_request_settings_invalid_station_4(self): + with self.assertRaises(AssertionError): + await self.ws.request_settings(station=4) + + # -- initialize_valves ------------------------------------------------------ + + async def test_initialize_valves_station_1(self): + await self.ws.initialize_valves(station=1) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="EJ", ep=1 + ) + + async def test_initialize_valves_station_2(self): + await self.ws.initialize_valves(station=2) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="EJ", ep=2 + ) + + async def test_initialize_valves_station_3(self): + await self.ws.initialize_valves(station=3) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="EJ", ep=3 + ) + + async def test_initialize_valves_invalid_station_0(self): + with self.assertRaises(AssertionError): + await self.ws.initialize_valves(station=0) + + async def test_initialize_valves_invalid_station_4(self): + with self.assertRaises(AssertionError): + await self.ws.initialize_valves(station=4) + + # -- fill_chamber ----------------------------------------------------------- + + async def test_fill_chamber_defaults(self): + """Default: station=1, drain_before_refill=False, wash_fluid=1, chamber=2 -> connection 0.""" + await self.ws.fill_chamber() + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="EH", + ep=1, + ed=False, + ek=0, + eu="00", + wait=False, + ) + + async def test_fill_chamber_wash_fluid_1_chamber_1(self): + """wash_fluid=1, chamber=1 -> connection 1.""" + await self.ws.fill_chamber(station=1, wash_fluid=1, chamber=1) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="EH", + ep=1, + ed=False, + ek=1, + eu="00", + wait=False, + ) + + async def test_fill_chamber_wash_fluid_2_chamber_1(self): + """wash_fluid=2, chamber=1 -> connection 2.""" + await self.ws.fill_chamber(station=2, wash_fluid=2, chamber=1) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="EH", + ep=2, + ed=False, + ek=2, + eu="00", + wait=False, + ) + + async def test_fill_chamber_wash_fluid_2_chamber_2(self): + """wash_fluid=2, chamber=2 -> connection 3.""" + await self.ws.fill_chamber(station=3, wash_fluid=2, chamber=2) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="EH", + ep=3, + ed=False, + ek=3, + eu="00", + wait=False, + ) + + async def test_fill_chamber_drain_before_refill(self): + await self.ws.fill_chamber(station=1, drain_before_refill=True, wash_fluid=1, chamber=2) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="EH", + ep=1, + ed=True, + ek=0, + eu="00", + wait=False, + ) + + async def test_fill_chamber_suck_time(self): + await self.ws.fill_chamber( + station=1, + wash_fluid=1, + chamber=2, + waste_chamber_suck_time_after_sensor_change=15, + ) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="EH", + ep=1, + ed=False, + ek=0, + eu="15", + wait=False, + ) + + async def test_fill_chamber_invalid_station_0(self): + with self.assertRaises(AssertionError): + await self.ws.fill_chamber(station=0) + + async def test_fill_chamber_invalid_station_4(self): + with self.assertRaises(AssertionError): + await self.ws.fill_chamber(station=4) + + async def test_fill_chamber_invalid_wash_fluid_0(self): + with self.assertRaises(AssertionError): + await self.ws.fill_chamber(wash_fluid=0) + + async def test_fill_chamber_invalid_wash_fluid_3(self): + with self.assertRaises(AssertionError): + await self.ws.fill_chamber(wash_fluid=3) + + async def test_fill_chamber_invalid_chamber_0(self): + with self.assertRaises(AssertionError): + await self.ws.fill_chamber(chamber=0) + + async def test_fill_chamber_invalid_chamber_3(self): + with self.assertRaises(AssertionError): + await self.ws.fill_chamber(chamber=3) + + # -- drain ------------------------------------------------------------------ + + async def test_drain_station_1(self): + await self.ws.drain(station=1) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="EL", ep=1 + ) + + async def test_drain_station_2(self): + await self.ws.drain(station=2) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="EL", ep=2 + ) + + async def test_drain_station_3(self): + await self.ws.drain(station=3) + self.mock_driver.send_command.assert_called_once_with( + module="C0", command="EL", ep=3 + ) + + async def test_drain_invalid_station_0(self): + with self.assertRaises(AssertionError): + await self.ws.drain(station=0) + + async def test_drain_invalid_station_4(self): + with self.assertRaises(AssertionError): + await self.ws.drain(station=4) diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/x_arm_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/x_arm_tests.py new file mode 100644 index 00000000000..0f20a020f1b --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/x_arm_tests.py @@ -0,0 +1,153 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.hamilton.liquid_handlers.star.x_arm import STARXArm + + +class TestSTARXArmCommands(unittest.IsolatedAsyncioTestCase): + """Test that STARXArm methods produce the exact same firmware commands as the legacy + STARBackend equivalents, for both left and right arms.""" + + async def asyncSetUp(self): + self.mock_driver = MagicMock() + self.mock_driver.send_command = AsyncMock() + self.left_arm = STARXArm(driver=self.mock_driver, side="left") + self.right_arm = STARXArm(driver=self.mock_driver, side="right") + + # -- move_to (C0:JX / C0:JS) ---------------------------------------------- + + async def test_left_move_to(self): + await self.left_arm.move_to(x_position=500.0) # 500 mm -> 05000 in 0.1mm + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="JX", + xs="05000", + ) + + async def test_right_move_to(self): + await self.right_arm.move_to(x_position=500.0) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="JS", + xs="05000", + ) + + async def test_left_move_to_default(self): + await self.left_arm.move_to() + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="JX", + xs="00000", + ) + + async def test_right_move_to_default(self): + await self.right_arm.move_to() + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="JS", + xs="00000", + ) + + # -- move_to_safe (C0:KX / C0:KR) ----------------------------------------- + + async def test_left_move_to_safe(self): + await self.left_arm.move_to_safe(x_position=1000.0) # 1000 mm -> 10000 in 0.1mm + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="KX", + xs=10000, + ) + + async def test_right_move_to_safe(self): + await self.right_arm.move_to_safe(x_position=1000.0) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="KR", + xs=10000, + ) + + async def test_left_move_to_safe_default(self): + await self.left_arm.move_to_safe() + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="KX", + xs=0, + ) + + async def test_right_move_to_safe_default(self): + await self.right_arm.move_to_safe() + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="KR", + xs=0, + ) + + # -- request_position (C0:RX / C0:QX) ------------------------------------- + + async def test_left_request_position(self): + self.mock_driver.send_command.return_value = {"rx": 15000} + result = await self.left_arm.request_position() + self.assertEqual(result, 1500.0) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="RX", + fmt="rx#####", + ) + + async def test_right_request_position(self): + self.mock_driver.send_command.return_value = {"rx": 15000} + result = await self.right_arm.request_position() + self.assertEqual(result, 1500.0) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="QX", + fmt="rx#####", + ) + + # -- last_collision_type (C0:XX / C0:XR) ----------------------------------- + + async def test_left_last_collision_type_true(self): + self.mock_driver.send_command.return_value = {"xq": 1} + result = await self.left_arm.last_collision_type() + self.assertTrue(result) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="XX", + fmt="xq#", + ) + + async def test_left_last_collision_type_false(self): + self.mock_driver.send_command.return_value = {"xq": 0} + result = await self.left_arm.last_collision_type() + self.assertFalse(result) + + async def test_right_last_collision_type_true(self): + self.mock_driver.send_command.return_value = {"xq": 1} + result = await self.right_arm.last_collision_type() + self.assertTrue(result) + self.mock_driver.send_command.assert_called_once_with( + module="C0", + command="XR", + fmt="xq#", + ) + + async def test_right_last_collision_type_false(self): + self.mock_driver.send_command.return_value = {"xq": 0} + result = await self.right_arm.last_collision_type() + self.assertFalse(result) + + # -- assertion checks ------------------------------------------------------ + + async def test_move_to_rejects_out_of_range(self): + with self.assertRaises(AssertionError): + await self.left_arm.move_to(x_position=-1) + with self.assertRaises(AssertionError): + await self.left_arm.move_to(x_position=3001) + with self.assertRaises(AssertionError): + await self.right_arm.move_to(x_position=-1) + + async def test_move_to_safe_rejects_out_of_range(self): + with self.assertRaises(AssertionError): + await self.left_arm.move_to_safe(x_position=-1) + with self.assertRaises(AssertionError): + await self.right_arm.move_to_safe(x_position=3001) diff --git a/pylabrobot/hamilton/liquid_handlers/star/wash_station.py b/pylabrobot/hamilton/liquid_handlers/star/wash_station.py new file mode 100644 index 00000000000..984a21500b3 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/wash_station.py @@ -0,0 +1,118 @@ +"""STARWashStation: wash/pump station control for Hamilton STAR liquid handlers.""" + +from __future__ import annotations + +import enum +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .driver import STARDriver + +logger = logging.getLogger(__name__) + + +class STARWashStation: + """Controls a wash / pump station on a Hamilton STAR. + + This is a plain helper class (not a CapabilityBackend). It encapsulates the + firmware protocol for the dual-chamber pump station subsystem and delegates + I/O to the driver. + """ + + def __init__(self, driver: "STARDriver"): + self._driver = driver + + class Type(enum.IntEnum): + """Pump station type enumeration.""" + CORE_96_SINGLE = 0 + DC_SINGLE_REV_02 = 1 + RERERE_SINGLE = 2 + CORE_96_DUAL = 3 + DC_DUAL = 4 + RERERE_DUAL = 5 + + async def request_settings(self, station: int = 1) -> "Type": + """Query pump station type (C0:ET). + + Args: + station: pump station number (1..3). + + Returns: + Pump station type code: + 0 = CoRe 96 wash station (single chamber) + 1 = DC wash station (single chamber rev 02) + 2 = ReReRe (single chamber) + 3 = CoRe 96 wash station (dual chamber) + 4 = DC wash station (dual chamber) + 5 = ReReRe (dual chamber) + """ + + assert 1 <= station <= 3, "station must be between 1 and 3" + + resp = await self._driver.send_command(module="C0", command="ET", fmt="et#", ep=station) + return STARWashStation.Type(resp["et"]) + + async def initialize_valves(self, station: int = 1): + """Initialize pump station valves — dual chamber only (C0:EJ). + + Args: + station: pump station number (1..3). + """ + + assert 1 <= station <= 3, "station must be between 1 and 3" + + return await self._driver.send_command(module="C0", command="EJ", ep=station) + + async def fill_chamber( + self, + station: int = 1, + drain_before_refill: bool = False, + wash_fluid: int = 1, + chamber: int = 2, + waste_chamber_suck_time_after_sensor_change: int = 0, + ): + """Fill selected dual chamber (C0:EH). + + The wash fluid / chamber combination is encoded as a connection index: + 0 = wash fluid 1 <-> chamber 2 + 1 = wash fluid 1 <-> chamber 1 + 2 = wash fluid 2 <-> chamber 1 + 3 = wash fluid 2 <-> chamber 2 + + Args: + station: pump station number (1..3). + drain_before_refill: drain chamber before refill. + wash_fluid: wash fluid selector (1 or 2). + chamber: chamber selector (1 or 2). + waste_chamber_suck_time_after_sensor_change: suck time in seconds after sensor + change (for error handling only). + """ + + assert 1 <= station <= 3, "station must be between 1 and 3" + assert 1 <= wash_fluid <= 2, "wash_fluid must be between 1 and 2" + assert 1 <= chamber <= 2, "chamber must be between 1 and 2" + + # wash fluid <-> chamber connection + connection = {(1, 2): 0, (1, 1): 1, (2, 1): 2, (2, 2): 3}[wash_fluid, chamber] + + return await self._driver.send_command( + module="C0", + command="EH", + ep=station, + ed=drain_before_refill, + ek=connection, + eu=f"{waste_chamber_suck_time_after_sensor_change:02}", + wait=False, + ) + + async def drain(self, station: int = 1): + """Drain dual chamber system (C0:EL). + + Args: + station: pump station number (1..3). + """ + + assert 1 <= station <= 3, "station must be between 1 and 3" + + return await self._driver.send_command(module="C0", command="EL", ep=station) diff --git a/pylabrobot/hamilton/liquid_handlers/star/x_arm.py b/pylabrobot/hamilton/liquid_handlers/star/x_arm.py new file mode 100644 index 00000000000..0573c4d2a48 --- /dev/null +++ b/pylabrobot/hamilton/liquid_handlers/star/x_arm.py @@ -0,0 +1,93 @@ +"""STARXArm: X-arm positioning control for Hamilton STAR liquid handlers.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from .driver import STARDriver + +logger = logging.getLogger(__name__) + + +class STARXArm: + """Controls one X-arm (left or right) on a Hamilton STAR. + + This is a plain helper class (not a CapabilityBackend). It encapsulates the + firmware protocol for X-arm positioning and delegates I/O to the driver. + + Args: + driver: The STARDriver instance to send commands through. + side: Which X-arm to control — ``"left"`` or ``"right"``. + """ + + def __init__(self, driver: "STARDriver", side: Literal["left", "right"]): + self._driver = driver + self._side = side + + # -- positioning (collision risk) ------------------------------------------ + + async def move_to(self, x_position: float = 0.0): + """Position X-arm (C0:JX for left, C0:JS for right). + + Collision risk! This moves the arm without raising components to Z-safety. + + Args: + x_position: X-position in mm. Must be between 0 and 3000. Default 0. + """ + + assert 0 <= x_position <= 3000.0, "x_position must be between 0 and 3000 mm" + + cmd = "JX" if self._side == "left" else "JS" + return await self._driver.send_command( + module="C0", + command=cmd, + xs=f"{round(x_position * 10):05}", + ) + + # -- safe positioning (Z-safety) ------------------------------------------- + + async def move_to_safe(self, x_position: float = 0.0): + """Move X-arm to position with all attached components in Z-safety position + (C0:KX for left, C0:KR for right). + + Args: + x_position: X-position in mm. Must be between 0 and 3000. Default 0. + """ + + assert 0 <= x_position <= 3000.0, "x_position must be between 0 and 3000 mm" + + cmd = "KX" if self._side == "left" else "KR" + return await self._driver.send_command( + module="C0", + command=cmd, + xs=round(x_position * 10), + ) + + # -- position query -------------------------------------------------------- + + async def request_position(self) -> float: + """Request current X-arm position (C0:RX for left, C0:QX for right). + + Returns: + X-position in mm (firmware value divided by 10). + """ + + cmd = "RX" if self._side == "left" else "QX" + resp = await self._driver.send_command(module="C0", command=cmd, fmt="rx#####") + return float(resp["rx"]) / 10 + + # -- collision type query -------------------------------------------------- + + async def last_collision_type(self) -> bool: + """Request last collision type after error 27 (C0:XX for left, C0:XR for right). + + Returns: + False if present positions collide (not reachable), + True if position is never reachable. + """ + + cmd = "XX" if self._side == "left" else "XR" + resp = await self._driver.send_command(module="C0", command=cmd, fmt="xq#") + return resp["xq"] == 1 diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py index 3e479f03f9a..a8a3113523a 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py @@ -1127,6 +1127,25 @@ def _channel_to_int(channel: str) -> int: return None +def _convert_immersion_depth( + immersion_depth: Optional[List[float]], + immersion_depth_direction: Optional[List[int]], +) -> Optional[List[float]]: + """Convert legacy (unsigned depth + direction flag) to new (signed depth). + + New API: positive = go deeper, negative = go up. + Legacy: immersion_depth is unsigned, direction 0 = deeper, 1 = up. + """ + if immersion_depth is None: + return None + if immersion_depth_direction is None: + return immersion_depth # already correct sign convention + return [ + d * (-1 if direction == 1 else 1) + for d, direction in zip(immersion_depth, immersion_depth_direction) + ] + + def _dispensing_mode_for_op(empty: bool, jet: bool, blow_out: bool) -> int: """from docs: 0 = Partial volume in jet mode @@ -1350,6 +1369,21 @@ def __init__( self._setup_done = False + # New-architecture backends — legacy methods forward to these. + from pylabrobot.hamilton.liquid_handlers.star.pip_backend import STARPIPBackend + from pylabrobot.hamilton.liquid_handlers.star.head96_backend import STARHead96Backend + from pylabrobot.hamilton.liquid_handlers.star.autoload import STARAutoload + from pylabrobot.hamilton.liquid_handlers.star.cover import STARCover + from pylabrobot.hamilton.liquid_handlers.star.wash_station import STARWashStation + from pylabrobot.hamilton.liquid_handlers.star.x_arm import STARXArm + self._new_pip = STARPIPBackend(self) + self._new_head96 = STARHead96Backend(self) + self._new_autoload = STARAutoload(driver=self) # type: ignore[arg-type] + self._new_cover = STARCover(driver=self) # type: ignore[arg-type] + self._new_left_x_arm = STARXArm(driver=self, side="left") # type: ignore[arg-type] + self._new_right_x_arm = STARXArm(driver=self, side="right") # type: ignore[arg-type] + self._new_wash_station = STARWashStation(driver=self) # type: ignore[arg-type] + def _min_spacing_between(self, i: int, j: int) -> float: """Return the conservative minimum Y spacing required between channels *i* and *j*. @@ -1639,6 +1673,7 @@ async def setup( # Request machine information self._machine_conf = await self.request_machine_configuration() self._extended_conf = await self.request_extended_configuration() + self._new_autoload._instrument_size_slots = self._extended_conf.instrument_size_slots self._head96_information: Optional[Head96Information] = None initialized = await self.request_instrument_initialization_status() @@ -1870,64 +1905,17 @@ async def pick_up_tips( minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, pickup_method: Optional[TipPickupMethod] = None, ): - """Pick up tips from a resource.""" - - self.ensure_can_reach_position(use_channels, ops, "pick_up_tips") - - x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) - - tip_spots = [op.resource for op in ops] - tips = set(cast(HamiltonTip, tip_spot.get_tip()) for tip_spot in tip_spots) - if len(tips) > 1: - raise ValueError("Cannot mix tips with different tip types.") - ttti = await self.get_or_assign_tip_type_index(tips.pop()) - - max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) - max_total_tip_length = max(op.tip.total_tip_length for op in ops) - max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) + from pylabrobot.capabilities.liquid_handling.standard import Pickup as NewPickup + PickUpTipsParams = self._new_pip.PickUpTipsParams - # not sure why this is necessary, but it is according to log files and experiments - if self._get_hamilton_tip([op.resource for op in ops]).tip_size == TipSize.LOW_VOLUME: - max_tip_length += 2 - elif self._get_hamilton_tip([op.resource for op in ops]).tip_size != TipSize.STANDARD_VOLUME: - max_tip_length -= 2 - - tip = ops[0].tip - if not isinstance(tip, HamiltonTip): - raise TypeError("Tip type must be HamiltonTip.") - - begin_tip_pick_up_process = ( - round((max_z + max_total_tip_length) * 10) - if begin_tip_pick_up_process is None - else int(begin_tip_pick_up_process * 10) + new_ops = [NewPickup(resource=op.resource, offset=op.offset, tip=op.tip) for op in ops] + params = PickUpTipsParams( + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height, + pickup_method=pickup_method, + begin_tip_pick_up_process=begin_tip_pick_up_process, + end_tip_pick_up_process=end_tip_pick_up_process, ) - end_tip_pick_up_process = ( - round((max_z + max_tip_length) * 10) - if end_tip_pick_up_process is None - else round(end_tip_pick_up_process * 10) - ) - minimum_traverse_height_at_beginning_of_a_command = ( - round(self._channel_traversal_height * 10) - if minimum_traverse_height_at_beginning_of_a_command is None - else round(minimum_traverse_height_at_beginning_of_a_command * 10) - ) - pickup_method = pickup_method or tip.pickup_method - - try: - return await self.pick_up_tip( - x_positions=x_positions, - y_positions=y_positions, - tip_pattern=channels_involved, - tip_type_idx=ttti, - begin_tip_pick_up_process=begin_tip_pick_up_process, - end_tip_pick_up_process=end_tip_pick_up_process, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, - pickup_method=pickup_method, - ) - except STARFirmwareError as e: - if plr_e := convert_star_firmware_error_to_plr_error(e): - raise plr_e from e - raise e + return await self._new_pip.pick_up_tips(new_ops, use_channels, backend_params=params) async def drop_tips( self, @@ -1939,78 +1927,18 @@ async def drop_tips( minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, z_position_at_end_of_a_command: Optional[float] = None, ): - """Drop tips to a resource. - - Args: - drop_method: The method to use for dropping tips. If None, the default method for dropping to - tip spots is `DROP`, and everything else is `PLACE_SHIFT`. Note that `DROP` is only the - default if *all* tips are being dropped to a tip spot. - """ - - self.ensure_can_reach_position(use_channels, ops, "drop_tips") - - if drop_method is None: - if any(not isinstance(op.resource, TipSpot) for op in ops): - drop_method = TipDropMethod.PLACE_SHIFT - else: - drop_method = TipDropMethod.DROP - - x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) - - # get highest z position - max_z = max(op.resource.get_location_wrt(self.deck).z + op.offset.z for op in ops) - if drop_method == TipDropMethod.PLACE_SHIFT: - # magic values empirically found in https://github.com/PyLabRobot/pylabrobot/pull/63 - begin_tip_deposit_process = ( - round((max_z + 59.9) * 10) - if begin_tip_deposit_process is None - else round(begin_tip_deposit_process * 10) - ) - end_tip_deposit_process = ( - round((max_z + 49.9) * 10) - if end_tip_deposit_process is None - else round(end_tip_deposit_process * 10) - ) - else: - max_total_tip_length = max(op.tip.total_tip_length for op in ops) - max_tip_length = max((op.tip.total_tip_length - op.tip.fitting_depth) for op in ops) - begin_tip_deposit_process = ( - round((max_z + max_total_tip_length) * 10) - if begin_tip_deposit_process is None - else round(begin_tip_deposit_process * 10) - ) - end_tip_deposit_process = ( - round((max_z + max_tip_length) * 10) - if end_tip_deposit_process is None - else round(end_tip_deposit_process * 10) - ) + from pylabrobot.capabilities.liquid_handling.standard import TipDrop as NewTipDrop + DropTipsParams = self._new_pip.DropTipsParams - minimum_traverse_height_at_beginning_of_a_command = ( - round(self._channel_traversal_height * 10) - if minimum_traverse_height_at_beginning_of_a_command is None - else round(minimum_traverse_height_at_beginning_of_a_command * 10) - ) - z_position_at_end_of_a_command = ( - round(self._channel_traversal_height * 10) - if z_position_at_end_of_a_command is None - else round(z_position_at_end_of_a_command * 10) + new_ops = [NewTipDrop(resource=op.resource, offset=op.offset, tip=op.tip) for op in ops] + params = DropTipsParams( + drop_method=drop_method, + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height, + z_position_at_end_of_a_command=z_position_at_end_of_a_command or self._channel_traversal_height, + begin_tip_deposit_process=begin_tip_deposit_process, + end_tip_deposit_process=end_tip_deposit_process, ) - - try: - return await self.discard_tip( - x_positions=x_positions, - y_positions=y_positions, - tip_pattern=channels_involved, - begin_tip_deposit_process=begin_tip_deposit_process, - end_tip_deposit_process=end_tip_deposit_process, - minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command, - z_position_at_end_of_a_command=z_position_at_end_of_a_command, - discarding_method=drop_method, - ) - except STARFirmwareError as e: - if plr_e := convert_star_firmware_error_to_plr_error(e): - raise plr_e from e - raise e + return await self._new_pip.drop_tips(new_ops, use_channels, backend_params=params) def _assert_valid_resources(self, resources: Sequence[Resource]) -> None: """Assert that resources are in a valid location for pipetting.""" @@ -2701,13 +2629,16 @@ async def aspirate( auto_surface_following_distance: automatically compute the surface following distance based on the container height<->volume functions. Requires liquid height to be specified or `probe_liquid_height=True`. """ + from pylabrobot.capabilities.liquid_handling.standard import Aspiration as NewAspiration + AspirateParams = self._new_pip.AspirateParams + from pylabrobot.hamilton.liquid_handlers.star.pip_backend import LLDMode as NewLLDMode + # # # TODO: delete > 2026-01 # # # if mix_volume is not None or mix_cycles is not None or mix_speed is not None: raise NotImplementedError( "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.aspirate instead. " "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" ) - if immersion_depth_direction is not None: warnings.warn( "The immersion_depth_direction parameter is deprecated and will be removed in the future. " @@ -2715,7 +2646,6 @@ async def aspirate( "out of the liquid.", DeprecationWarning, ) - if liquid_surfaces_no_lld is not None: warnings.warn( "The liquid_surfaces_no_lld parameter is deprecated and will be removed in the future. " @@ -2723,274 +2653,66 @@ async def aspirate( DeprecationWarning, ) liquid_surface_no_lld = liquid_surface_no_lld or liquid_surfaces_no_lld - # # # delete # # # - - self.ensure_can_reach_position(use_channels, ops, "aspirate") - - x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) - - n = len(ops) - - if jet is None: - jet = [False] * n - if blow_out is None: - blow_out = [False] * n - - if hamilton_liquid_classes is None: - hamilton_liquid_classes = [] - for i, op in enumerate(ops): - hamilton_liquid_classes.append( - get_star_liquid_class( - tip_volume=op.tip.maximal_volume, - is_core=False, - is_tip=True, - has_filter=op.tip.has_filter, - liquid=Liquid.WATER, # default to WATER - jet=jet[i], - blow_out=blow_out[i], - ) - ) - - # correct volumes using the liquid class - disable_volume_correction = fill_in_defaults(disable_volume_correction, [False] * n) - volumes = [ - hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume - for op, hlc, disabled in zip(ops, hamilton_liquid_classes, disable_volume_correction) - ] - - well_bottoms = [ - op.resource.get_location_wrt(self.deck).z + op.offset.z + op.resource.material_z_thickness - for op in ops - ] - if lld_search_height is None: - lld_search_height = [ - ( - wb + op.resource.get_absolute_size_z() + (2.7 if isinstance(op.resource, Well) else 5) - ) # ? - for wb, op in zip(well_bottoms, ops) - ] - else: - lld_search_height = [(wb + sh) for wb, sh in zip(well_bottoms, lld_search_height)] - clot_detection_height = fill_in_defaults( - clot_detection_height, - default=[ - hlc.aspiration_clot_retract_height if hlc is not None else 0.0 - for hlc in hamilton_liquid_classes - ], - ) - pull_out_distance_transport_air = fill_in_defaults(pull_out_distance_transport_air, [10] * n) - second_section_height = fill_in_defaults(second_section_height, [3.2] * n) - second_section_ratio = fill_in_defaults(second_section_ratio, [618.0] * n) - minimum_height = fill_in_defaults(minimum_height, well_bottoms) - if immersion_depth is None: - immersion_depth = [0.0] * n - immersion_depth_direction = immersion_depth_direction or [ - 0 if (id_ >= 0) else 1 for id_ in immersion_depth - ] - immersion_depth = [ - im * (-1 if immersion_depth_direction[i] else 1) for i, im in enumerate(immersion_depth) - ] - flow_rates = [ - op.flow_rate or (hlc.aspiration_flow_rate if hlc is not None else 100.0) - for op, hlc in zip(ops, hamilton_liquid_classes) - ] - transport_air_volume = fill_in_defaults( - transport_air_volume, - default=[ - hlc.aspiration_air_transport_volume if hlc is not None else 0.0 - for hlc in hamilton_liquid_classes - ], - ) - blow_out_air_volumes = [ - (op.blow_out_air_volume or (hlc.aspiration_blow_out_volume if hlc is not None else 0.0)) - for op, hlc in zip(ops, hamilton_liquid_classes) - ] - pre_wetting_volume = fill_in_defaults(pre_wetting_volume, [0.0] * n) - lld_mode = fill_in_defaults(lld_mode, [self.__class__.LLDMode.OFF] * n) - gamma_lld_sensitivity = fill_in_defaults(gamma_lld_sensitivity, [1] * n) - dp_lld_sensitivity = fill_in_defaults(dp_lld_sensitivity, [1] * n) - aspirate_position_above_z_touch_off = fill_in_defaults( - aspirate_position_above_z_touch_off, [0.0] * n - ) - detection_height_difference_for_dual_lld = fill_in_defaults( - detection_height_difference_for_dual_lld, [0.0] * n - ) - swap_speed = fill_in_defaults( - swap_speed, - default=[ - hlc.aspiration_swap_speed if hlc is not None else 100.0 for hlc in hamilton_liquid_classes - ], - ) - settling_time = fill_in_defaults( - settling_time, - default=[ - hlc.aspiration_settling_time if hlc is not None else 0.0 for hlc in hamilton_liquid_classes - ], - ) - mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] - mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] - mix_position_from_liquid_surface = fill_in_defaults(mix_position_from_liquid_surface, [0.0] * n) - mix_speed = [op.mix.flow_rate if op.mix is not None else 100.0 for op in ops] - mix_surface_following_distance = fill_in_defaults(mix_surface_following_distance, [0.0] * n) - limit_curve_index = fill_in_defaults(limit_curve_index, [0] * n) - - use_2nd_section_aspiration = fill_in_defaults(use_2nd_section_aspiration, [False] * n) - retract_height_over_2nd_section_to_empty_tip = fill_in_defaults( - retract_height_over_2nd_section_to_empty_tip, [0.0] * n - ) - dispensation_speed_during_emptying_tip = fill_in_defaults( - dispensation_speed_during_emptying_tip, [50.0] * n - ) - dosing_drive_speed_during_2nd_section_search = fill_in_defaults( - dosing_drive_speed_during_2nd_section_search, [50.0] * n - ) - z_drive_speed_during_2nd_section_search = fill_in_defaults( - z_drive_speed_during_2nd_section_search, [30.0] * n - ) - cup_upper_edge = fill_in_defaults(cup_upper_edge, [0.0] * n) - - # Deprecated params - warn if passed, but don't use them if ratio_liquid_rise_to_tip_deep_in is not None: - warnings.warn( - "ratio_liquid_rise_to_tip_deep_in is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) + warnings.warn("ratio_liquid_rise_to_tip_deep_in is deprecated.", DeprecationWarning, stacklevel=2) if immersion_depth_2nd_section is not None: - warnings.warn( - "immersion_depth_2nd_section is deprecated and will be removed in a future version.", - DeprecationWarning, - stacklevel=2, - ) + warnings.warn("immersion_depth_2nd_section is deprecated.", DeprecationWarning, stacklevel=2) + # # # delete # # # - if probe_liquid_height: - if any(op.liquid_height is not None for op in ops): - raise ValueError("Cannot use probe_liquid_height when liquid heights are set.") + # Convert lld_mode enums from legacy to new + new_lld_mode = None + if lld_mode is not None: + new_lld_mode = [NewLLDMode(m.value) for m in lld_mode] - liquid_heights = await self.probe_liquid_heights( - containers=[op.resource for op in ops], - use_channels=use_channels, - resource_offsets=[op.offset for op in ops], - move_to_z_safety_after=False, + new_ops = [ + NewAspiration( + resource=op.resource, offset=op.offset, tip=op.tip, volume=op.volume, + flow_rate=op.flow_rate, liquid_height=op.liquid_height, + blow_out_air_volume=op.blow_out_air_volume, + mix=op.mix, ) - - # override minimum traversal height because we don't want to move channels up. we are already above the liquid. - minimum_traverse_height_at_beginning_of_a_command = 100 - logger.info(f"Detected liquid heights: {liquid_heights}") - else: - liquid_heights = [op.liquid_height or 0 for op in ops] - - liquid_surfaces_no_lld = liquid_surface_no_lld or [ - wb + lh for wb, lh in zip(well_bottoms, liquid_heights) + for op in ops ] - if auto_surface_following_distance: - if any(op.liquid_height is None for op in ops) and not probe_liquid_height: - raise ValueError( - "To use auto_surface_following_distance all liquid heights must be set or probe_liquid_height must be True." - ) - - if any(not op.resource.supports_compute_height_volume_functions() for op in ops): - raise ValueError( - "automatic_surface_following can only be used with containers that support height<->volume functions." - ) - - current_volumes = [ - op.resource.compute_volume_from_height(liquid_heights[i]) for i, op in enumerate(ops) - ] - - # compute new liquid_height after aspiration - liquid_height_after_aspiration = [ - op.resource.compute_height_from_volume(current_volumes[i] - op.volume) - for i, op in enumerate(ops) - ] - - # compute new surface_following_distance - surface_following_distance = [ - liquid_heights[i] - liquid_height_after_aspiration[i] - for i in range(len(liquid_height_after_aspiration)) - ] - else: - surface_following_distance = fill_in_defaults(surface_following_distance, [0.0] * n) + params = AspirateParams( + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + jet=jet, + blow_out=blow_out, + lld_search_height=lld_search_height, + clot_detection_height=clot_detection_height, + pull_out_distance_transport_air=pull_out_distance_transport_air, + second_section_height=second_section_height, + second_section_ratio=second_section_ratio, + minimum_height=minimum_height, + immersion_depth=_convert_immersion_depth(immersion_depth, immersion_depth_direction), + surface_following_distance=surface_following_distance, + transport_air_volume=transport_air_volume, + pre_wetting_volume=pre_wetting_volume, + lld_mode=new_lld_mode, + gamma_lld_sensitivity=gamma_lld_sensitivity, + dp_lld_sensitivity=dp_lld_sensitivity, + aspirate_position_above_z_touch_off=aspirate_position_above_z_touch_off, + detection_height_difference_for_dual_lld=detection_height_difference_for_dual_lld, + swap_speed=swap_speed, + settling_time=settling_time, + mix_position_from_liquid_surface=mix_position_from_liquid_surface, + mix_surface_following_distance=mix_surface_following_distance, + limit_curve_index=limit_curve_index, + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height, + min_z_endpos=min_z_endpos or self._channel_traversal_height, + liquid_surface_no_lld=liquid_surface_no_lld, + use_2nd_section_aspiration=use_2nd_section_aspiration, + retract_height_over_2nd_section_to_empty_tip=retract_height_over_2nd_section_to_empty_tip, + dispensation_speed_during_emptying_tip=dispensation_speed_during_emptying_tip, + dosing_drive_speed_during_2nd_section_search=dosing_drive_speed_during_2nd_section_search, + z_drive_speed_during_2nd_section_search=z_drive_speed_during_2nd_section_search, + cup_upper_edge=cup_upper_edge, + probe_liquid_height=probe_liquid_height, + auto_surface_following_distance=auto_surface_following_distance, + ) - # check if the surface_following_distance would fall below the minimum height - # if lld is enabled, we expect to find liquid above the well bottom so we don't need to raise an error - if any( - ( - well_bottoms[i] + liquid_heights[i] - surface_following_distance[i] - minimum_height[i] - < -1e-6 - ) - and lld_mode[i] == STARBackend.LLDMode.OFF - for i in range(n) - ): - raise ValueError( - f"surface_following_distance would result in a height that goes below the minimum_height. " - f"Well bottom: {well_bottoms}, liquid height: {liquid_heights}, surface_following_distance: {surface_following_distance}, minimum_height: {minimum_height}" - ) - - try: - return await self.aspirate_pip( - aspiration_type=[0 for _ in range(n)], - tip_pattern=channels_involved, - x_positions=x_positions, - y_positions=y_positions, - aspiration_volumes=[round(vol * 10) for vol in volumes], - lld_search_height=[round(lsh * 10) for lsh in lld_search_height], - clot_detection_height=[round(cd * 10) for cd in clot_detection_height], - liquid_surface_no_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], - pull_out_distance_transport_air=[round(po * 10) for po in pull_out_distance_transport_air], - second_section_height=[round(sh * 10) for sh in second_section_height], - second_section_ratio=[round(sr * 10) for sr in second_section_ratio], - minimum_height=[round(mh * 10) for mh in minimum_height], - immersion_depth=[round(id_ * 10) for id_ in immersion_depth], - immersion_depth_direction=immersion_depth_direction, - surface_following_distance=[round(sfd * 10) for sfd in surface_following_distance], - aspiration_speed=[round(fr * 10) for fr in flow_rates], - transport_air_volume=[round(tav * 10) for tav in transport_air_volume], - blow_out_air_volume=[round(boa * 10) for boa in blow_out_air_volumes], - pre_wetting_volume=[round(pwv * 10) for pwv in pre_wetting_volume], - lld_mode=[mode.value for mode in lld_mode], - gamma_lld_sensitivity=gamma_lld_sensitivity, - dp_lld_sensitivity=dp_lld_sensitivity, - aspirate_position_above_z_touch_off=[ - round(ap * 10) for ap in aspirate_position_above_z_touch_off - ], - detection_height_difference_for_dual_lld=[ - round(dh * 10) for dh in detection_height_difference_for_dual_lld - ], - swap_speed=[round(ss * 10) for ss in swap_speed], - settling_time=[round(st * 10) for st in settling_time], - mix_volume=[round(hv * 10) for hv in mix_volume], - mix_cycles=mix_cycles, - mix_position_from_liquid_surface=[ - round(hp * 10) for hp in mix_position_from_liquid_surface - ], - mix_speed=[round(hs * 10) for hs in mix_speed], - mix_surface_following_distance=[round(hsd * 10) for hsd in mix_surface_following_distance], - limit_curve_index=limit_curve_index, - use_2nd_section_aspiration=use_2nd_section_aspiration, - retract_height_over_2nd_section_to_empty_tip=[ - round(rh * 10) for rh in retract_height_over_2nd_section_to_empty_tip - ], - dispensation_speed_during_emptying_tip=[ - round(ds * 10) for ds in dispensation_speed_during_emptying_tip - ], - dosing_drive_speed_during_2nd_section_search=[ - round(ds * 10) for ds in dosing_drive_speed_during_2nd_section_search - ], - z_drive_speed_during_2nd_section_search=[ - round(zs * 10) for zs in z_drive_speed_during_2nd_section_search - ], - cup_upper_edge=[round(cue * 10) for cue in cup_upper_edge], - minimum_traverse_height_at_beginning_of_a_command=round( - (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 - ), - min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), - ) - except STARFirmwareError as e: - if plr_e := convert_star_firmware_error_to_plr_error(e): - raise plr_e from e - raise e + return await self._new_pip.aspirate(new_ops, use_channels, backend_params=params) async def dispense( self, @@ -3087,24 +2809,18 @@ async def dispense( auto_surface_following_distance: automatically compute the surface following distance based on the container height<->volume functions. Requires liquid height to be specified or `probe_liquid_height=True`. """ - self.ensure_can_reach_position(use_channels, ops, "dispense") + from pylabrobot.capabilities.liquid_handling.standard import Dispense as NewDispense + DispenseParams = self._new_pip.DispenseParams + from pylabrobot.hamilton.liquid_handlers.star.pip_backend import LLDMode as NewLLDMode n = len(ops) - if jet is None: - jet = [False] * n - if empty is None: - empty = [False] * n - if blow_out is None: - blow_out = [False] * n - # # # TODO: delete > 2026-01 # # # if mix_volume is not None or mix_cycles is not None or mix_speed is not None: raise NotImplementedError( "Mixing through backend kwargs is deprecated. Use the `mix` parameter of LiquidHandler.dispense instead. " "https://docs.pylabrobot.org/user_guide/00_liquid-handling/mixing.html" ) - if immersion_depth_direction is not None: warnings.warn( "The immersion_depth_direction parameter is deprecated and will be removed in the future. " @@ -3112,220 +2828,62 @@ async def dispense( "out of the liquid.", DeprecationWarning, ) - if dispensing_mode is not None: warnings.warn( "The dispensing_mode parameter is deprecated and will be removed in the future. " - "Use the jet, blow_out and empty parameters instead. " - "dispensing_mode currently supersedes the other three parameters if both are provided.", + "Use the jet, blow_out and empty parameters instead.", DeprecationWarning, ) - dispensing_modes = dispensing_mode - else: - dispensing_modes = [ - _dispensing_mode_for_op(empty=empty[i], jet=jet[i], blow_out=blow_out[i]) - for i in range(len(ops)) - ] # # # delete # # # - x_positions, y_positions, channels_involved = self._ops_to_fw_positions(ops, use_channels) - - if hamilton_liquid_classes is None: - hamilton_liquid_classes = [] - for i, op in enumerate(ops): - hamilton_liquid_classes.append( - get_star_liquid_class( - tip_volume=op.tip.maximal_volume, - is_core=False, - is_tip=True, - has_filter=op.tip.has_filter, - liquid=Liquid.WATER, # default to WATER - jet=jet[i], - blow_out=blow_out[i], - ) - ) - - # correct volumes using the liquid class - disable_volume_correction = fill_in_defaults(disable_volume_correction, [False] * n) - volumes = [ - hlc.compute_corrected_volume(op.volume) if hlc is not None and not disabled else op.volume - for op, hlc, disabled in zip(ops, hamilton_liquid_classes, disable_volume_correction) - ] + new_lld_mode = None + if lld_mode is not None: + new_lld_mode = [NewLLDMode(m.value) for m in lld_mode] - well_bottoms = [ - op.resource.get_location_wrt(self.deck).z + op.offset.z + op.resource.material_z_thickness - for op in ops - ] - if lld_search_height is None: - lld_search_height = [ - ( - wb + op.resource.get_absolute_size_z() + (2.7 if isinstance(op.resource, Well) else 5) - ) # ? - for wb, op in zip(well_bottoms, ops) - ] - else: - lld_search_height = [wb + sh for wb, sh in zip(well_bottoms, lld_search_height)] - - pull_out_distance_transport_air = fill_in_defaults(pull_out_distance_transport_air, [10.0] * n) - second_section_height = fill_in_defaults(second_section_height, [3.2] * n) - second_section_ratio = fill_in_defaults(second_section_ratio, [618.0] * n) - minimum_height = fill_in_defaults(minimum_height, well_bottoms) - if immersion_depth is None: - immersion_depth = [0.0] * n - immersion_depth_direction = immersion_depth_direction or [ - 0 if (id_ >= 0) else 1 for id_ in immersion_depth - ] - immersion_depth = [ - im * (-1 if immersion_depth_direction[i] else 1) for i, im in enumerate(immersion_depth) - ] - flow_rates = [ - op.flow_rate or (hlc.dispense_flow_rate if hlc is not None else 120.0) - for op, hlc in zip(ops, hamilton_liquid_classes) - ] - cut_off_speed = fill_in_defaults(cut_off_speed, [5.0] * n) - stop_back_volume = fill_in_defaults( - stop_back_volume, - default=[ - hlc.dispense_stop_back_volume if hlc is not None else 0.0 for hlc in hamilton_liquid_classes - ], - ) - transport_air_volume = fill_in_defaults( - transport_air_volume, - default=[ - hlc.dispense_air_transport_volume if hlc is not None else 0.0 - for hlc in hamilton_liquid_classes - ], - ) - blow_out_air_volumes = [ - (op.blow_out_air_volume or (hlc.dispense_blow_out_volume if hlc is not None else 0.0)) - for op, hlc in zip(ops, hamilton_liquid_classes) - ] - lld_mode = fill_in_defaults(lld_mode, [self.__class__.LLDMode.OFF] * n) - dispense_position_above_z_touch_off = fill_in_defaults( - dispense_position_above_z_touch_off, default=[0] * n - ) - gamma_lld_sensitivity = fill_in_defaults(gamma_lld_sensitivity, [1] * n) - dp_lld_sensitivity = fill_in_defaults(dp_lld_sensitivity, [1] * n) - swap_speed = fill_in_defaults( - swap_speed, - default=[ - hlc.dispense_swap_speed if hlc is not None else 10.0 for hlc in hamilton_liquid_classes - ], - ) - settling_time = fill_in_defaults( - settling_time, - default=[ - hlc.dispense_settling_time if hlc is not None else 0.0 for hlc in hamilton_liquid_classes - ], - ) - mix_volume = [op.mix.volume if op.mix is not None else 0.0 for op in ops] - mix_cycles = [op.mix.repetitions if op.mix is not None else 0 for op in ops] - mix_position_from_liquid_surface = fill_in_defaults(mix_position_from_liquid_surface, [0.0] * n) - mix_speed = [op.mix.flow_rate if op.mix is not None else 1.0 for op in ops] - mix_surface_following_distance = fill_in_defaults(mix_surface_following_distance, [0.0] * n) - limit_curve_index = fill_in_defaults(limit_curve_index, [0] * n) - - if probe_liquid_height: - if any(op.liquid_height is not None for op in ops): - raise ValueError("Cannot use probe_liquid_height when liquid heights are set.") - - liquid_heights = await self.probe_liquid_heights( - containers=[op.resource for op in ops], - use_channels=use_channels, - resource_offsets=[op.offset for op in ops], - move_to_z_safety_after=False, + new_ops = [ + NewDispense( + resource=op.resource, offset=op.offset, tip=op.tip, volume=op.volume, + flow_rate=op.flow_rate, liquid_height=op.liquid_height, + blow_out_air_volume=op.blow_out_air_volume, + mix=op.mix, ) - - # override minimum traversal height because we don't want to move channels up. we are already above the liquid. - minimum_traverse_height_at_beginning_of_a_command = 100 - logger.info(f"Detected liquid heights: {liquid_heights}") - else: - liquid_heights = [op.liquid_height or 0 for op in ops] - - if auto_surface_following_distance: - if any(op.liquid_height is None for op in ops) and not probe_liquid_height: - raise ValueError( - "To use auto_surface_following_distance all liquid heights must be set or probe_liquid_height must be True." - ) - - if any(not op.resource.supports_compute_height_volume_functions() for op in ops): - raise ValueError( - "automatic_surface_following can only be used with containers that support height<->volume functions." - ) - - current_volumes = [ - op.resource.compute_volume_from_height(liquid_heights[i]) for i, op in enumerate(ops) - ] - - # compute new liquid_height after aspiration - liquid_height_after_aspiration = [ - op.resource.compute_height_from_volume(current_volumes[i] + op.volume) - for i, op in enumerate(ops) - ] - - # compute new surface_following_distance - surface_following_distance = [ - liquid_height_after_aspiration[i] - liquid_heights[i] - for i in range(len(liquid_height_after_aspiration)) - ] - else: - surface_following_distance = fill_in_defaults(surface_following_distance, [0.0] * n) - - liquid_surfaces_no_lld = liquid_surface_no_lld or [ - wb + lh for wb, lh in zip(well_bottoms, liquid_heights) + for op in ops ] - try: - ret = await self.dispense_pip( - tip_pattern=channels_involved, - x_positions=x_positions, - y_positions=y_positions, - dispensing_mode=dispensing_modes, - dispense_volumes=[round(vol * 10) for vol in volumes], - lld_search_height=[round(lsh * 10) for lsh in lld_search_height], - liquid_surface_no_lld=[round(ls * 10) for ls in liquid_surfaces_no_lld], - pull_out_distance_transport_air=[round(po * 10) for po in pull_out_distance_transport_air], - second_section_height=[round(sh * 10) for sh in second_section_height], - second_section_ratio=[round(sr * 10) for sr in second_section_ratio], - minimum_height=[round(mh * 10) for mh in minimum_height], - immersion_depth=[round(id_ * 10) for id_ in immersion_depth], - immersion_depth_direction=immersion_depth_direction, - surface_following_distance=[round(sfd * 10) for sfd in surface_following_distance], - dispense_speed=[round(fr * 10) for fr in flow_rates], - cut_off_speed=[round(cs * 10) for cs in cut_off_speed], - stop_back_volume=[round(sbv * 10) for sbv in stop_back_volume], - transport_air_volume=[round(tav * 10) for tav in transport_air_volume], - blow_out_air_volume=[round(boa * 10) for boa in blow_out_air_volumes], - lld_mode=[mode.value for mode in lld_mode], - dispense_position_above_z_touch_off=[ - round(dp * 10) for dp in dispense_position_above_z_touch_off - ], - gamma_lld_sensitivity=gamma_lld_sensitivity, - dp_lld_sensitivity=dp_lld_sensitivity, - swap_speed=[round(ss * 10) for ss in swap_speed], - settling_time=[round(st * 10) for st in settling_time], - mix_volume=[round(mv * 10) for mv in mix_volume], - mix_cycles=mix_cycles, - mix_position_from_liquid_surface=[ - round(mp * 10) for mp in mix_position_from_liquid_surface - ], - mix_speed=[round(ms * 10) for ms in mix_speed], - mix_surface_following_distance=[ - round(msfd * 10) for msfd in mix_surface_following_distance - ], - limit_curve_index=limit_curve_index, - minimum_traverse_height_at_beginning_of_a_command=round( - (minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height) * 10 - ), - min_z_endpos=round((min_z_endpos or self._channel_traversal_height) * 10), - side_touch_off_distance=round(side_touch_off_distance * 10), - ) - except STARFirmwareError as e: - if plr_e := convert_star_firmware_error_to_plr_error(e): - raise plr_e from e - raise e + params = DispenseParams( + hamilton_liquid_classes=hamilton_liquid_classes, + disable_volume_correction=disable_volume_correction, + jet=jet, + blow_out=blow_out, + empty=empty, + lld_search_height=lld_search_height, + liquid_surface_no_lld=liquid_surface_no_lld, + pull_out_distance_transport_air=pull_out_distance_transport_air, + second_section_height=second_section_height, + second_section_ratio=second_section_ratio, + minimum_height=minimum_height, + immersion_depth=_convert_immersion_depth(immersion_depth, immersion_depth_direction), + surface_following_distance=surface_following_distance, + cut_off_speed=cut_off_speed, + stop_back_volume=stop_back_volume, + transport_air_volume=transport_air_volume, + lld_mode=new_lld_mode, + side_touch_off_distance=side_touch_off_distance, + dispense_position_above_z_touch_off=dispense_position_above_z_touch_off, + gamma_lld_sensitivity=gamma_lld_sensitivity, + dp_lld_sensitivity=dp_lld_sensitivity, + swap_speed=swap_speed, + settling_time=settling_time, + mix_position_from_liquid_surface=mix_position_from_liquid_surface, + mix_surface_following_distance=mix_surface_following_distance, + limit_curve_index=limit_curve_index, + minimum_traverse_height_at_beginning_of_a_command=minimum_traverse_height_at_beginning_of_a_command or self._channel_traversal_height, + min_z_endpos=min_z_endpos or self._channel_traversal_height, + probe_liquid_height=probe_liquid_height, + auto_surface_following_distance=auto_surface_following_distance, + ) - return ret + return await self._new_pip.dispense(new_ops, use_channels, backend_params=params) @_requires_head96 async def pick_up_tips96( @@ -4684,7 +4242,7 @@ def _check_96_position_legal(self, c: Coordinate, skip_z=False) -> None: # -------------- 3.2 System general commands -------------- async def pre_initialize_instrument(self): - """Pre-initialize instrument""" + """Deprecated: use ``star._driver.pre_initialize_instrument()``.""" return await self.send_command(module="C0", command="VI", read_timeout=300) async def define_tip_needle( @@ -4729,32 +4287,17 @@ async def define_tip_needle( # -------------- 3.2.1 System query -------------- async def request_error_code(self): - """Request error code - - Here the last saved error messages can be retrieved. The error buffer is automatically voided - when a new command is started. All configured nodes are displayed. - - Returns: - TODO: - X0##/##: X0 slave - ..##/## see node definitions ( chapter 5) - """ + """Deprecated: use ``star._driver.request_error_code()``.""" return await self.send_command(module="C0", command="RE") async def request_firmware_version(self): - """Request firmware version - - Returns: TODO: Rfid0001rf1.0S 2009-06-24 A - """ + """Deprecated: use ``star._driver.request_firmware_version()``.""" return await self.send_command(module="C0", command="RF") async def request_parameter_value(self): - """Request parameter value - - Returns: TODO: Raid1111er00/00yg1200 - """ + """Deprecated: use ``star._driver.request_parameter_value()``.""" return await self.send_command(module="C0", command="RA") @@ -4766,11 +4309,7 @@ class BoardType(enum.Enum): UNKNOWN = -1 async def request_electronic_board_type(self): - """Request electronic board type - - Returns: - The board type. - """ + """Deprecated: use ``star._driver.request_electronic_board_type()``.""" resp = await self.send_command(module="C0", command="QB") try: @@ -4778,63 +4317,39 @@ async def request_electronic_board_type(self): except ValueError: return STARBackend.BoardType.UNKNOWN - # TODO: parse response. async def request_supply_voltage(self): - """Request supply voltage - - Request supply voltage (for LDPB only) - """ + """Deprecated: use ``star._driver.request_supply_voltage()``.""" return await self.send_command(module="C0", command="MU") async def request_instrument_initialization_status(self) -> bool: - """Request instrument initialization status""" + """Deprecated: use ``star._driver.request_instrument_initialization_status()``.""" resp = await self.send_command(module="C0", command="QW", fmt="qw#") return resp is not None and resp["qw"] == 1 async def request_autoload_initialization_status(self) -> bool: - """Request autoload initialization status""" - - resp = await self.send_command(module="I0", command="QW", fmt="qw#") - return resp is not None and resp["qw"] == 1 + """Deprecated: use ``star.autoload.request_initialization_status()``.""" + return await self._new_autoload.request_initialization_status() async def request_name_of_last_faulty_parameter(self): - """Request name of last faulty parameter - - Returns: TODO: - Name of last parameter with syntax error - (optional) received value separated with blank - (optional) minimal permitted value separated with blank (optional) - maximal permitted value separated with blank example with min max data: - Vpid2233er00/00vpth 00000 03500 example without min max data: Vpid2233er00/00vpcd - """ + """Deprecated: use ``star._driver.request_name_of_last_faulty_parameter()``.""" return await self.send_command(module="C0", command="VP", fmt="vp&&") async def request_master_status(self): - """Request master status - - Returns: TODO: see page 19 (SFCO.0036) - """ + """Deprecated: use ``star._driver.request_master_status()``.""" return await self.send_command(module="C0", command="RQ") async def request_number_of_presence_sensors_installed(self): - """Request number of presence sensors installed - - Returns: - number of sensors installed (1...103) - """ + """Deprecated: use ``star._driver.request_number_of_presence_sensors_installed()``.""" resp = await self.send_command(module="C0", command="SR") return resp["sr"] async def request_eeprom_data_correctness(self): - """Request EEPROM data correctness - - Returns: TODO: (SFCO.0149) - """ + """Deprecated: use ``star._driver.request_eeprom_data_correctness()``.""" return await self.send_command(module="C0", command="QV") @@ -4843,11 +4358,7 @@ async def request_eeprom_data_correctness(self): # -------------- 3.3.1 Volatile Settings -------------- async def set_single_step_mode(self, single_step_mode: bool = False): - """Set Single step mode - - Args: - single_step_mode: Single Step Mode. Default False. - """ + """Deprecated: use ``star._driver.set_single_step_mode()``.""" return await self.send_command( module="C0", @@ -4856,35 +4367,23 @@ async def set_single_step_mode(self, single_step_mode: bool = False): ) async def trigger_next_step(self): - """Trigger next step (Single step mode)""" + """Deprecated: use ``star._driver.trigger_next_step()``.""" # TODO: this command has no reply!!!! return await self.send_command(module="C0", command="NS") async def halt(self): - """Halt - - Intermediate sequences not yet carried out and the commands in - the command stack are discarded. Sequence already in process is - completed. - """ + """Deprecated: use ``star._driver.halt()``.""" return await self.send_command(module="C0", command="HD") async def save_all_cycle_counters(self): - """Save all cycle counters - - Save all cycle counters of the instrument - """ + """Deprecated: use ``star._driver.save_all_cycle_counters()``.""" return await self.send_command(module="C0", command="AZ") async def set_not_stop(self, non_stop): - """Set not stop mode - - Args: - non_stop: True if non stop mode should be turned on after command is sent. - """ + """Deprecated: use ``star._driver.set_not_stop()``.""" if non_stop: # TODO: this command has no reply!!!! @@ -4899,11 +4398,7 @@ async def store_installation_data( date: datetime.datetime = datetime.datetime.now(), serial_number: str = "0000", ): - """Store installation data - - Args: - date: installation date. - """ + """Deprecated: use ``star._driver.store_installation_data()``.""" assert len(serial_number) == 4, "serial number must be 4 chars long" @@ -4915,13 +4410,7 @@ async def store_verification_data( date: datetime.datetime = datetime.datetime.now(), verification_status: bool = False, ): - """Store verification data - - Args: - verification_subject: verification subject. Default 0. Must be between 0 and 24. - date: verification date. - verification_status: verification status. - """ + """Deprecated: use ``star._driver.store_verification_data()``.""" assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" @@ -4934,43 +4423,27 @@ async def store_verification_data( ) async def additional_time_stamp(self): - """Additional time stamp""" + """Deprecated: use ``star._driver.additional_time_stamp()``.""" return await self.send_command(module="C0", command="AT") async def set_x_offset_x_axis_iswap(self, x_offset: int): - """Set X-offset X-axis <-> iSWAP - - Args: - x_offset: X-offset [0.1mm] - """ + """Deprecated: use ``star._driver.set_x_offset_x_axis_iswap()``.""" return await self.send_command(module="C0", command="AG", x_offset=x_offset) async def set_x_offset_x_axis_core_96_head(self, x_offset: int): - """Set X-offset X-axis <-> CoRe 96 head - - Args: - x_offset: X-offset [0.1mm] - """ + """Deprecated: use ``star._driver.set_x_offset_x_axis_core_96_head()``.""" return await self.send_command(module="C0", command="AF", x_offset=x_offset) async def set_x_offset_x_axis_core_nano_pipettor_head(self, x_offset: int): - """Set X-offset X-axis <-> CoRe 96 head - - Args: - x_offset: X-offset [0.1mm] - """ + """Deprecated: use ``star._driver.set_x_offset_x_axis_core_nano_pipettor_head()``.""" return await self.send_command(module="C0", command="AF", x_offset=x_offset) async def save_download_date(self, date: datetime.datetime = datetime.datetime.now()): - """Save Download date - - Args: - date: download date. Default now. - """ + """Deprecated: use ``star._driver.save_download_date()``.""" return await self.send_command( module="C0", @@ -4979,12 +4452,7 @@ async def save_download_date(self, date: datetime.datetime = datetime.datetime.n ) async def save_technical_status_of_assemblies(self, processor_board: str, power_supply: str): - """Save technical status of assemblies - - Args: - processor_board: Processor board. Art.Nr./Rev./Ser.No. (000000/00/0000) - power_supply: Power supply. Art.Nr./Rev./Ser.No. (000000/00/0000) - """ + """Deprecated: use ``star._driver.save_technical_status_of_assemblies()``.""" return await self.send_command( module="C0", @@ -5016,46 +4484,7 @@ async def set_instrument_configuration( left_arm_minimal_y_position: int = 60, right_arm_minimal_y_position: int = 60, ): - """Set instrument configuration - - Args: - configuration_data_1: configuration data 1. - configuration_data_2: configuration data 2. - configuration_data_3: configuration data 3. - instrument_size_in_slots_x_range: instrument size in slots (X range). - Must be between 10 and 99. Default 54. - auto_load_size_in_slots: auto load size in slots. Must be between 10 - and 54. Default 54. - tip_waste_x_position: tip waste X-position. Must be between 1000 and - 25000. Default 13400. - right_x_drive_configuration_byte_1: right X drive configuration byte 1 (see - xl parameter bits). Must be between 0 and 1. Default 0. # TODO: this. - right_x_drive_configuration_byte_2: right X drive configuration byte 2 (see - xn parameter bits). Must be between 0 and 1. Default 0. # TODO: this. - minimal_iswap_collision_free_position: minimal iSWAP collision free position for - direct X access. For explanation of calculation see Fig. 4. Must be between 0 and 30000. - Default 3500. - maximal_iswap_collision_free_position: maximal iSWAP collision free position for - direct X access. For explanation of calculation see Fig. 4. Must be between 0 and 30000. - Default 11400 - left_x_arm_width: width of left X arm [0.1 mm]. Must be between 0 and 9999. Default 3700. - right_x_arm_width: width of right X arm [0.1 mm]. Must be between 0 and 9999. Default 3700. - num_pip_channels: number of PIP channels. Must be between 0 and 16. Default 0. - num_xl_channels: number of XL channels. Must be between 0 and 8. Default 0. - num_robotic_channels: number of Robotic channels. Must be between 0 and 8. Default 0. - minimal_raster_pitch_of_pip_channels: minimal raster pitch of PIP channels [0.1 mm]. Must - be between 0 and 999. Default 90. - minimal_raster_pitch_of_xl_channels: minimal raster pitch of XL channels [0.1 mm]. Must be - between 0 and 999. Default 360. - minimal_raster_pitch_of_robotic_channels: minimal raster pitch of Robotic channels [0.1 mm]. - Must be between 0 and 999. Default 360. - pip_maximal_y_position: PIP maximal Y position [0.1 mm]. Must be between 0 and 9999. - Default 6065. - left_arm_minimal_y_position: left arm minimal Y position [0.1 mm]. Must be between 0 and 9999. - Default 60. - right_arm_minimal_y_position: right arm minimal Y position [0.1 mm]. Must be between 0 - and 9999. Default 60. - """ + """Deprecated: use ``star._driver.set_instrument_configuration()``.""" assert 1 <= instrument_size_in_slots_x_range <= 9, ( "instrument_size_in_slots_x_range must be between 1 and 99" @@ -5123,11 +4552,7 @@ async def set_instrument_configuration( ) async def save_pip_channel_validation_status(self, validation_status: bool = False): - """Save PIP channel validation status - - Args: - validation_status: PIP channel validation status. Default False. - """ + """Deprecated: use ``star._driver.save_pip_channel_validation_status()``.""" return await self.send_command( module="C0", @@ -5136,11 +4561,7 @@ async def save_pip_channel_validation_status(self, validation_status: bool = Fal ) async def save_xl_channel_validation_status(self, validation_status: bool = False): - """Save XL channel validation status - - Args: - validation_status: XL channel validation status. Default False. - """ + """Deprecated: use ``star._driver.save_xl_channel_validation_status()``.""" return await self.send_command( module="C0", @@ -5150,17 +4571,12 @@ async def save_xl_channel_validation_status(self, validation_status: bool = Fals # TODO: response async def configure_node_names(self): - """Configure node names""" + """Deprecated: use ``star._driver.configure_node_names()``.""" return await self.send_command(module="C0", command="AJ") async def set_deck_data(self, data_index: int = 0, data_stream: str = "0"): - """set deck data - - Args: - data_index: data index. Must be between 0 and 9. Default 0. - data_stream: data stream (12 characters). Default . - """ + """Deprecated: use ``star._driver.set_deck_data()``.""" assert 0 <= data_index <= 9, "data_index must be between 0 and 9" assert len(data_stream) == 12, "data_stream must be 12 chars" @@ -5175,33 +4591,29 @@ async def set_deck_data(self, data_index: int = 0, data_stream: str = "0"): # -------------- 3.3.3 Settings query (stored in EEPROM) -------------- async def request_technical_status_of_assemblies(self): - """Request Technical status of assemblies""" + """Deprecated: use ``star._driver.request_technical_status_of_assemblies()``.""" # TODO: parse res return await self.send_command(module="C0", command="QT") async def request_installation_data(self): - """Request installation data""" + """Deprecated: use ``star._driver.request_installation_data()``.""" # TODO: parse res return await self.send_command(module="C0", command="RI") async def request_device_serial_number(self) -> str: - """Request device serial number""" + """Deprecated: use ``star._driver.request_device_serial_number()``.""" return (await self.send_command("C0", "RI", fmt="si####sn&&&&sn&&&&"))["sn"] # type: ignore async def request_download_date(self): - """Request download date""" + """Deprecated: use ``star._driver.request_download_date()``.""" # TODO: parse res return await self.send_command(module="C0", command="RO") async def request_verification_data(self, verification_subject: int = 0): - """Request download date - - Args: - verification_subject: verification subject. Must be between 0 and 24. Default 0. - """ + """Deprecated: use ``star._driver.request_verification_data()``.""" assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" @@ -5209,19 +4621,19 @@ async def request_verification_data(self, verification_subject: int = 0): return await self.send_command(module="C0", command="RO", vo=verification_subject) async def request_additional_timestamp_data(self): - """Request additional timestamp data""" + """Deprecated: use ``star._driver.request_additional_timestamp_data()``.""" # TODO: parse res return await self.send_command(module="C0", command="RS") async def request_pip_channel_validation_status(self): - """Request PIP channel validation status""" + """Deprecated: use ``star._driver.request_pip_channel_validation_status()``.""" # TODO: parse res return await self.send_command(module="C0", command="RJ") async def request_xl_channel_validation_status(self): - """Request XL channel validation status""" + """Deprecated: use ``star._driver.request_xl_channel_validation_status()``.""" # TODO: parse res return await self.send_command(module="C0", command="UJ") @@ -5320,13 +4732,13 @@ def _parse_drive(byte1: int, byte2: int) -> DriveConfiguration: ) async def request_node_names(self): - """Request node names""" + """Deprecated: use ``star._driver.request_node_names()``.""" # TODO: parse res return await self.send_command(module="C0", command="RK") async def request_deck_data(self): - """Request deck data""" + """Deprecated: use ``star._driver.request_deck_data()``.""" # TODO: parse res return await self.send_command(module="C0", command="VD") @@ -5336,72 +4748,24 @@ async def request_deck_data(self): # -------------- 3.4.1 Movements -------------- async def position_left_x_arm_(self, x_position: int = 0): - """Position left X-Arm - - Collision risk! - - Args: - x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. - """ - - assert 0 <= x_position <= 30000, "x_position_ must be between 0 and 30000" - - return await self.send_command( - module="C0", - command="JX", - xs=f"{x_position:05}", - ) + """Deprecated: use ``star.left_x_arm.move_to()``.""" + return await self._new_left_x_arm.move_to(x_position=x_position / 10) async def position_right_x_arm_(self, x_position: int = 0): - """Position right X-Arm - - Collision risk! - - Args: - x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. - """ - - assert 0 <= x_position <= 30000, "x_position_ must be between 0 and 30000" - - return await self.send_command( - module="C0", - command="JS", - xs=f"{x_position:05}", - ) + """Deprecated: use ``star.right_x_arm.move_to()``.""" + return await self._new_right_x_arm.move_to(x_position=x_position / 10) async def move_left_x_arm_to_position_with_all_attached_components_in_z_safety_position( self, x_position: int = 0 - ): - """Move left X-arm to position with all attached components in Z-safety position - - Args: - x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - - return await self.send_command( - module="C0", - command="KX", - xs=x_position, - ) + ): + """Deprecated: use ``star.left_x_arm.move_to_safe()``.""" + return await self._new_left_x_arm.move_to_safe(x_position=x_position / 10) async def move_right_x_arm_to_position_with_all_attached_components_in_z_safety_position( self, x_position: int = 0 ): - """Move right X-arm to position with all attached components in Z-safety position - - Args: - x_position: X-Position [0.1mm]. Must be between 0 and 30000. Default 0. - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - - return await self.send_command( - module="C0", - command="KR", - xs=x_position, - ) + """Deprecated: use ``star.right_x_arm.move_to_safe()``.""" + return await self._new_right_x_arm.move_to_safe(x_position=x_position / 10) # -------------- 3.4.2 X-Area reservation for external access -------------- @@ -5413,18 +4777,7 @@ async def occupy_and_provide_area_for_external_access( taken_area_size: int = 0, arm_preposition_mode_related_to_taken_areas: int = 0, ): - """Occupy and provide area for external access - - Args: - taken_area_identification_number: taken area identification number. Must be between 0 and - 9999. Default 0. - taken_area_left_margin: taken area left margin. Must be between 0 and 99. Default 0. - taken_area_left_margin_direction: taken area left margin direction. 1 = negative. Must be - between 0 and 1. Default 0. - taken_area_size: taken area size. Must be between 0 and 50000. Default 0. - arm_preposition_mode_related_to_taken_areas: 0) left arm to left & right arm to right. - 1) all arms left. 2) all arms right. - """ + """Deprecated: use ``star._driver.occupy_and_provide_area_for_external_access()``.""" assert 0 <= taken_area_identification_number <= 9999, ( "taken_area_identification_number must be between 0 and 9999" @@ -5449,12 +4802,7 @@ async def occupy_and_provide_area_for_external_access( ) async def release_occupied_area(self, taken_area_identification_number: int = 0): - """Release occupied area - - Args: - taken_area_identification_number: taken area identification number. - Must be between 0 and 9999. Default 0. - """ + """Deprecated: use ``star._driver.release_occupied_area()``.""" assert 0 <= taken_area_identification_number <= 999, ( "taken_area_identification_number must be between 0 and 9999" @@ -5467,54 +4815,37 @@ async def release_occupied_area(self, taken_area_identification_number: int = 0) ) async def release_all_occupied_areas(self): - """Release all occupied areas""" + """Deprecated: use ``star._driver.release_all_occupied_areas()``.""" return await self.send_command(module="C0", command="BC") # -------------- 3.4.3 X-query -------------- async def request_left_x_arm_position(self) -> float: - """Request left X-Arm position""" - resp_dmm = await self.send_command(module="C0", command="RX", fmt="rx#####") - return cast(float, resp_dmm["rx"]) / 10 + """Deprecated: use ``star.left_x_arm.request_position()``.""" + return await self._new_left_x_arm.request_position() async def request_right_x_arm_position(self) -> float: - """Request right X-Arm position""" - - resp_dmm = await self.send_command(module="C0", command="QX", fmt="rx#####") - return cast(float, resp_dmm["rx"]) / 10 + """Deprecated: use ``star.right_x_arm.request_position()``.""" + return await self._new_right_x_arm.request_position() async def request_maximal_ranges_of_x_drives(self): - """Request maximal ranges of X drives""" + """Deprecated: use ``star._driver.request_maximal_ranges_of_x_drives()``.""" return await self.send_command(module="C0", command="RU") async def request_present_wrap_size_of_installed_arms(self): - """Request present wrap size of installed arms""" + """Deprecated: use ``star._driver.request_present_wrap_size_of_installed_arms()``.""" return await self.send_command(module="C0", command="UA") async def request_left_x_arm_last_collision_type(self): - """Request left X-Arm last collision type (after error 27) - - Returns: - False if present positions collide (not reachable), - True if position is never reachable. - """ - - resp = await self.send_command(module="C0", command="XX", fmt="xq#") - return resp["xq"] == 1 + """Deprecated: use ``star.left_x_arm.last_collision_type()``.""" + return await self._new_left_x_arm.last_collision_type() async def request_right_x_arm_last_collision_type(self) -> bool: - """Request right X-Arm last collision type (after error 27) - - Returns: - False if present positions collide (not reachable), - True if position is never reachable. - """ - - resp = await self.send_command(module="C0", command="XR", fmt="xq#") - return cast(int, resp["xq"]) == 1 + """Deprecated: use ``star.right_x_arm.last_collision_type()``.""" + return await self._new_right_x_arm.last_collision_type() # -------------- 3.5 Pipetting channel commands -------------- @@ -6311,22 +5642,19 @@ async def pick_up_core_gripper_tools( if front_offset is not None and back_offset is not None and front_offset.z != back_offset.z: raise ValueError("front_offset.z and back_offset.z must be the same") z_offset = 0 if front_offset is None else front_offset.z - begin_z_coord = round(235.0 + self.core_adjustment.z + z_offset) - end_z_coord = round(225.0 + self.core_adjustment.z + z_offset) - command_output = await self.send_command( - module="C0", - command="ZT", - xs=f"{round(xs * 10):05}", - xd="0", - ya=f"{round(back_channel_y_center * 10):04}", - yb=f"{round(front_channel_y_center * 10):04}", - pa=f"{back_channel + 1:02}", # star is 1-indexed - pb=f"{front_channel + 1:02}", # star is 1-indexed - tp=f"{round(begin_z_coord * 10):04}", - tz=f"{round(end_z_coord * 10):04}", - th=round(self._iswap_traversal_height * 10), - tt="14", + from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver + + command_output = await STARDriver.pick_up_core_gripper_tools( + self, + x_position=xs, + back_channel_y=back_channel_y_center, + front_channel_y=front_channel_y_center, + back_channel=back_channel, + front_channel=front_channel, + begin_z=235.0 + self.core_adjustment.z + z_offset, + end_z=225.0 + self.core_adjustment.z + z_offset, + traversal_height=self._iswap_traversal_height, ) self._core_parked = False return command_output @@ -6358,20 +5686,17 @@ async def return_core_gripper_tools( if front_offset is not None and back_offset is not None and back_offset.z != front_offset.z: raise ValueError("back_offset.z and front_offset.z must be the same") z_offset = 0 if front_offset is None else front_offset.z - begin_z_coord = round(215.0 + self.core_adjustment.z + z_offset) - end_z_coord = round(205.0 + self.core_adjustment.z + z_offset) - command_output = await self.send_command( - module="C0", - command="ZS", - xs=f"{round(xs * 10):05}", - xd="0", - ya=f"{round(back_channel_y_center * 10):04}", - yb=f"{round(front_channel_y_center * 10):04}", - tp=f"{round(begin_z_coord * 10):04}", - tz=f"{round(end_z_coord * 10):04}", - th=round(self._iswap_traversal_height * 10), - te=round(self._iswap_traversal_height * 10), + from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver + + command_output = await STARDriver.return_core_gripper_tools( + self, + x_position=xs, + back_channel_y=back_channel_y_center, + front_channel_y=front_channel_y_center, + begin_z=215.0 + self.core_adjustment.z + z_offset, + end_z=205.0 + self.core_adjustment.z + z_offset, + traversal_height=self._iswap_traversal_height, ) self._core_parked = True return command_output @@ -8455,9 +7780,8 @@ async def initialize_auto_load(self): return await self.initialize_autoload() async def initialize_autoload(self): - """Initialize Auto load module""" - - return await self.send_command(module="C0", command="II") + """Deprecated: use ``star.autoload.initialize()``.""" + return await self._new_autoload.initialize() async def move_auto_load_to_z_save_position(self): """Deprecated - use `move_autoload_to_safe_z_position` instead.""" @@ -8482,9 +7806,8 @@ async def move_autoload_to_save_z_position(self): return await self.move_autoload_to_safe_z_position() async def move_autoload_to_safe_z_position(self): - """Move autoload carrier handling wheel to safe Z position""" - - return await self.send_command(module="C0", command="IV") + """Deprecated: use ``star.autoload.move_to_safe_z_position()``.""" + return await self._new_autoload.move_to_safe_z_position() async def request_auto_load_slot_position(self): """Deprecated - use `request_autoload_track` instead.""" @@ -8497,144 +7820,31 @@ async def request_auto_load_slot_position(self): return await self.request_autoload_track() async def request_autoload_track(self) -> int: - """Request current track of the autoload 'carrier handler'. - - Returns: - track (0..54) - """ - resp = await self.send_command(module="C0", command="QA", fmt="qa##") - return int(resp["qa"]) + """Deprecated: use ``star.autoload.request_track()``.""" + return await self._new_autoload.request_track() async def request_autoload_type(self) -> str: - """ - Query the autoload module type. - - This sends the `C0:QA` command, which returns a CQ-format response containing - the autoload identification fields, error/trace information, and the module - type code. The `cq` field specifies the autoload hardware type: - - 0 = ML-STAR with 1D Barcode Scanner - 1 = XRP Lite - 2 = ML-STAR with 2D Barcode Scanner - 3-9 = Reserved / other module variants - - Returns: - int: The autoload module type code (0-9). - """ - - autoload_type_dict = { - 0: "ML-STAR with 1D Barcode Scanner", - 1: "XRP Lite", - 2: "ML-STAR with 2D Barcode Scanner", - } - - resp = await self.send_command(module="C0", command="CQ", fmt="cq#") - resp = autoload_type_dict[resp["cq"]] if resp["cq"] in autoload_type_dict else resp["cq"] - - return str(resp) + """Deprecated: use ``star.autoload.request_type()``.""" + return await self._new_autoload.request_type() # -------------- 3.13.2 Carrier sensing -------------- def _decode_hex_bitmask_to_track_list(self, mask_hex: str) -> list[int]: - """ - Decode a hex occupancy bitmask of arbitrary length. - Each hex nibble = 4 slots. - Slot numbering starts at 1 from the rightmost nibble (LSB). - """ - mask_hex = mask_hex.strip() - - if not all(c in "0123456789abcdefABCDEF" for c in mask_hex): - raise ValueError(f"Invalid hex in mask: {mask_hex!r}") - - slots = [] - bit_index = 1 - - # Rightmost hex digit = slot 1 (LSB) - for nibble in reversed(mask_hex): - val = int(nibble, 16) - for bit in range(4): - if val & (1 << bit): - slots.append(bit_index) - bit_index += 1 - - return sorted(slots) + """Deprecated: use ``STARAutoload._decode_hex_bitmask_to_track_list()``.""" + from pylabrobot.hamilton.liquid_handlers.star.autoload import STARAutoload + return STARAutoload._decode_hex_bitmask_to_track_list(mask_hex) async def request_presence_of_carriers_on_deck(self) -> list[int]: - """ - Read the deck carrier presence sensors and return the positions where carriers - are currently detected. - - This sends the `C0:RC` command to query the rear deck sensors. No autoload - movement is performed. The returned hex bitmask is decoded into a list of - track numbers (1-54), where each number corresponds to a deck rail position - that is occupied by a carrier. - - Returns: - list[int]: Sorted list of deck rail positions where carriers are present. - """ - resp = await self.send_command(module="C0", command="RC") - - ce_resp = resp.split("ce")[-1] - - return self._decode_hex_bitmask_to_track_list(ce_resp) + """Deprecated: use ``star.autoload.request_presence_of_carriers_on_deck()``.""" + return await self._new_autoload.request_presence_of_carriers_on_deck() async def request_presence_of_carriers_on_loading_tray(self) -> list[int]: - """ - Moves autoload sled across loading tray and reads its front-facing proximity sensors - to determine which tray positions contain carriers. - - This sends the `C0:CS` command, which provides a hex-encoded presence bitmask - for the loading tray. The bitmask is decoded into a list of track numbers (1-54) - representing tray positions that currently contain a carrier. - - Returns: - list[int]: Sorted list of loading-tray positions where carriers are present. - - Raises: - ValueError: If the response is missing the expected 'cd' field. - """ - resp = await self.send_command(module="C0", command="CS") - - if "cd" not in resp: - raise ValueError(f"CD field missing: {resp!r}") - - mask_hex = resp.split("cd", 1)[1].strip() - - return self._decode_hex_bitmask_to_track_list(mask_hex) + """Deprecated: use ``star.autoload.request_presence_of_carriers_on_loading_tray()``.""" + return await self._new_autoload.request_presence_of_carriers_on_loading_tray() async def request_presence_of_single_carrier_on_loading_tray(self, track: int) -> bool: - """ - Check whether a specific loading-tray track contains a carrier. - - This sends the `C0:CT` command, which instructs the autoload sled to move to - the specified tray track and read its front-facing proximity sensor. Unlike - `request_presence_of_carriers_on_loading_tray`, which scans all tray - positions and returns a bitmask, this method queries only a single track and - returns a boolean result. - - Args: - track (int): The loading-tray track number to query (1-54). - - Returns: - bool: True if a carrier is detected at the given track; False otherwise. - - Raises: - AssertionError: If `track` is outside the valid range (1-54). - """ - - assert 1 <= track <= 54, "track must be between 1 and 54" - - track_str = str(track).zfill(2) - - resp = await self.send_command( - module="C0", - command="CT", - fmt="ct#", - cp=track_str, - ) - assert resp is not None - - return int(resp["ct"]) == 1 + """Deprecated: use ``star.autoload.request_presence_of_single_carrier_on_loading_tray()``.""" + return await self._new_autoload.request_presence_of_single_carrier_on_loading_tray(track) async def request_single_carrier_presence(self, carrier_position: int): """Request single carrier presence on the loading tray (not on deck)""" @@ -8671,50 +7881,17 @@ async def move_autoload_to_slot(self, slot_number: int): return await self.move_autoload_to_track(track=slot_number) async def move_autoload_to_track(self, track: int): - """Move autoload to specific slot/track position""" - - assert 1 <= track <= 54, "track must be between 1 and 54" - - await self.move_autoload_to_safe_z_position() - - track_no_as_safe_str = str(track).zfill(2) - return await self.send_command(module="I0", command="XP", xp=track_no_as_safe_str) + """Deprecated: use ``star.autoload.move_to_track()``.""" + return await self._new_autoload.move_to_track(track) async def park_autoload(self): - """Park autoload""" - - # Identify max number of x positions for your liquid handler - max_x_pos = str(self.extended_conf.instrument_size_slots).zfill(2) - - await self.move_autoload_to_safe_z_position() - - # Park autoload to max x position available - return await self.send_command(module="I0", command="XP", xp=max_x_pos) + """Deprecated: use ``star.autoload.park()``.""" + return await self._new_autoload.park() async def take_carrier_out_to_autoload_belt(self, carrier: Carrier): - """Take carrier out to identification position for barcode reading. - Start: carrier is already on the deck - """ - - # Identify carrier end rail + """Deprecated: use ``star.autoload.take_carrier_out_to_belt()``.""" carrier_end_rail = self._compute_end_rail_of_carrier(carrier) - - carrier_on_loading_tray = await self.request_single_carrier_presence(carrier_end_rail) - - if not carrier_on_loading_tray: - try: - await self.send_command( - module="C0", - command="CN", - cp=str(carrier_end_rail).zfill(2), - ) - except Exception as e: - await self.move_autoload_to_safe_z_position() - raise RuntimeError( - f"Failed to take carrier at rail {carrier_end_rail} out to autoload belt: {e}" - ) - else: - raise ValueError(f"Carrier is already on the loading tray at position {carrier_end_rail}.") + return await self._new_autoload.take_carrier_out_to_belt(carrier_end_rail) # -------------- 3.13.4 Autoload barcode reading commands -------------- @@ -8746,22 +7923,8 @@ async def set_1d_barcode_type( self, barcode_symbology: Optional[Barcode1DSymbology], ) -> None: - """Set 1D barcode type for autoload barcode reading.""" - - # If none given, use the default - if barcode_symbology is None: - barcode_symbology = self._default_1d_symbology - - # Prove to mypy that barcode_symbology is no longer Optional - assert barcode_symbology is not None - - await self.send_command( - module="C0", - command="CB", - bt=self.barcode_1d_symbology_dict[barcode_symbology], - ) - - self._default_1d_symbology = barcode_symbology + """Deprecated: use ``star.autoload.set_1d_barcode_type()``.""" + return await self._new_autoload.set_1d_barcode_type(barcode_symbology) async def set_barcode_type( self, @@ -8810,73 +7973,24 @@ async def load_carrier_from_tray_and_scan_carrier_barcode( barcode_reading_window_width: float = 38.0, # mm reading_speed: float = 128.1, # mm/sec ) -> Optional[Barcode]: - """Load carrier from loading tray and - optionally - scan 1D carrier barcode""" - - if barcode_symbology is None: - barcode_symbology = self._default_1d_symbology - - assert barcode_symbology is not None - + """Deprecated: use ``star.autoload.load_carrier_from_tray_and_scan_carrier_barcode()``.""" carrier_end_rail = self._compute_end_rail_of_carrier(carrier) - carrier_end_rail_str = str(carrier_end_rail).zfill(2) - - assert 1 <= int(carrier_end_rail_str) <= 54 - assert 0 <= barcode_position <= 470 - assert 0.1 <= barcode_reading_window_width <= 99.9 - assert 1.5 <= reading_speed <= 160.0 - - try: - resp = await self.send_command( - module="C0", - command="CI", - cp=carrier_end_rail_str, - bi=f"{round(barcode_position * 10):04}", - bw=f"{round(barcode_reading_window_width * 10):03}", - co="0960", # Distance between containers (pattern) [0.1 mm] - cv=f"{round(reading_speed * 10):04}", - ) - except Exception as e: - if carrier_barcode_reading: - await self.move_autoload_to_safe_z_position() - raise RuntimeError( - f"Failed to load carrier at rail {carrier_end_rail} and scan barcode: {e}" - ) - else: - pass - - if not carrier_barcode_reading: - return None - - barcode_str = resp.split("bb/")[-1] - - return Barcode(data=barcode_str, symbology=barcode_symbology, position_on_resource="right") + return await self._new_autoload.load_carrier_from_tray_and_scan_carrier_barcode( + carrier_end_rail=carrier_end_rail, + carrier_barcode_reading=carrier_barcode_reading, + barcode_symbology=barcode_symbology, + barcode_position=barcode_position, + barcode_reading_window_width=barcode_reading_window_width, + reading_speed=reading_speed, + ) async def unload_carrier_after_carrier_barcode_scanning(self): - """After scanning the barcode of the carrier currently engaged with - the autoload sled, unload the carrier back to the loading tray. - """ - try: - resp = await self.send_command( - module="C0", - command="CA", - ) - except Exception as e: - await self.move_autoload_to_safe_z_position() - raise RuntimeError(f"Failed to unload carrier after barcode scanning: {e}") - - return resp + """Deprecated: use ``star.autoload.unload_carrier_after_barcode_scanning()``.""" + return await self._new_autoload.unload_carrier_after_barcode_scanning() async def set_carrier_monitoring(self, should_monitor: bool = False): - """Set carrier monitoring - - Args: - should_monitor: whether carrier should be monitored. - - Returns: - True if present, False otherwise - """ - - return await self.send_command(module="C0", command="CU", cu=should_monitor) + """Deprecated: use ``star.autoload.set_carrier_monitoring()``.""" + return await self._new_autoload.set_carrier_monitoring(should_monitor) async def load_carrier_from_autoload_belt( self, @@ -8890,82 +8004,18 @@ async def load_carrier_from_autoload_belt( reading_speed: float = 128.1, # mm/secs park_autoload_after: bool = True, ) -> dict[int, Optional[Barcode]]: - """Finishes loading the carrier that is currently engaged with the autoload sled, - i.e. is currently in the identification position. - """ - - assert barcode_reading_direction in ["horizontal", "vertical"] - assert 0 <= reading_position_of_first_barcode <= 470 - assert 0 <= no_container_per_carrier <= 32 - assert 0 <= distance_between_containers <= 470 - assert 0.1 <= width_of_reading_window <= 99.9 - assert 1.5 <= reading_speed <= 160.0 - - barcode_reading_direction_dict = { - "vertical": "0", - "horizontal": "1", - } - - if barcode_symbology is None: - barcode_symbology = self._default_1d_symbology - assert barcode_symbology is not None - - no_container_per_carrier_str = str(no_container_per_carrier).zfill(2) - reading_position_of_first_barcode_str = str( - round(reading_position_of_first_barcode * 10) - ).zfill(4) - distance_between_containers_str = str(round(distance_between_containers * 10)).zfill(4) - width_of_reading_window_str = str(round(width_of_reading_window * 10)).zfill(3) - reading_speed_str = str(round(reading_speed * 10)).zfill(4) - - if not barcode_reading: - barcode_reading_direction = "vertical" # no movement - no_container_per_carrier_str = "00" # no scanning - - else: - # Choose barcode symbology - await self.set_1d_barcode_type(barcode_symbology=barcode_symbology) - - self._default_1d_symbology = barcode_symbology - - try: - resp = await self.send_command( - module="C0", - command="CL", - bd=barcode_reading_direction_dict[barcode_reading_direction], - bp=reading_position_of_first_barcode_str, # Barcode reading position of first barcode [mm] - cn=no_container_per_carrier_str, - co=distance_between_containers_str, # Distance between containers (pattern) [mm] - cf=width_of_reading_window_str, # Width of reading window [mm] - cv=reading_speed_str, # Carrier reading speed [mm/sec]/ - ) - except Exception as e: - await self.move_autoload_to_safe_z_position() - raise RuntimeError(f"Failed to load carrier from autoload belt: {e}") - - if park_autoload_after: - await self.park_autoload() - - assert isinstance(resp, str), f"Response is not a string: {resp!r}" - - barcode_dict: dict[int, Optional[Barcode]] = {} - - if barcode_reading: - resp_list = resp.split("bb/")[-1].split("/") # remove header - - assert len(resp_list) == no_container_per_carrier, ( - f"Number of barcodes read ({len(resp_list)}) does not match " - f"expected number ({no_container_per_carrier})" - ) - for i in range(0, no_container_per_carrier): - if resp_list[i] == "00": - barcode_dict[i] = None - else: - barcode_dict[i] = Barcode( - data=resp_list[i], symbology=barcode_symbology, position_on_resource="right" - ) - - return barcode_dict + """Deprecated: use ``star.autoload.load_carrier_from_belt()``.""" + return await self._new_autoload.load_carrier_from_belt( + barcode_reading=barcode_reading, + barcode_reading_direction=barcode_reading_direction, + barcode_symbology=barcode_symbology, + reading_position_of_first_barcode=reading_position_of_first_barcode, + no_container_per_carrier=no_container_per_carrier, + distance_between_containers=distance_between_containers, + width_of_reading_window=width_of_reading_window, + reading_speed=reading_speed, + park_autoload_after=park_autoload_after, + ) # -------------- 3.13.5 Autoload carrier loading/unloading commands -------------- @@ -8983,235 +8033,60 @@ async def load_carrier( reading_speed: float = 128.1, # mm/secs park_autoload_after: bool = True, ) -> dict: - """ - Use autoload to load carrier. - - Args: - carrier: Carrier to load - barcode_reading: Whether to read barcodes. Default False. - barcode_reading_direction: Barcode reading direction. Either "vertical" or "horizontal", - default "horizontal". - barcode_symbology: Barcode symbology. Default "Code 128 (Subset B and C)". - no_container_per_carrier: Number of containers per carrier. Default 5. - park_autoload_after: Whether to park autoload after loading. Default True. - """ - - if barcode_symbology is None: - barcode_symbology = self._default_1d_symbology - - # Identify carrier end rail + """Deprecated: use ``star.autoload.load_carrier()``.""" carrier_end_rail = self._compute_end_rail_of_carrier(carrier) - assert 1 <= int(carrier_end_rail) <= 54, "carrier loading rail must be between 1 and 54" - - # Determine presence of carrier at defined position - presence_check = await self.request_presence_of_single_carrier_on_loading_tray(carrier_end_rail) - - if presence_check != 1: - raise ValueError( - f"""No carrier found at position {carrier_end_rail}, - have you placed the carrier onto the correct autoload tray position?""" - ) - - # Set carrier type for identification purposes - carrier_barcode = await self.load_carrier_from_tray_and_scan_carrier_barcode( - carrier, carrier_barcode_reading=carrier_barcode_reading - ) - - # Load carrier - # with barcoding - if barcode_reading: - # Choose barcode symbology - await self.set_1d_barcode_type(barcode_symbology=barcode_symbology) - self._default_1d_symbology = barcode_symbology - - # Load and read out barcodes # TODO: swap with load_carrier_from_autoload_belt? - resp = await self.load_carrier_from_autoload_belt( - barcode_reading=barcode_reading, - barcode_reading_direction=barcode_reading_direction, - barcode_symbology=barcode_symbology, - reading_position_of_first_barcode=reading_position_of_first_barcode, - no_container_per_carrier=no_container_per_carrier, - distance_between_containers=distance_between_containers, - width_of_reading_window=width_of_reading_window, - reading_speed=reading_speed, - park_autoload_after=False, - ) - else: # without barcoding - resp = await self.load_carrier_from_autoload_belt( - barcode_reading=False, park_autoload_after=False - ) - - if park_autoload_after: - await self.park_autoload() - - # Parse response and create output dict - output = { - "carrier_barcode": carrier_barcode if carrier_barcode_reading else None, - "container_barcodes": resp if barcode_reading else None, - } - - return output + return await self._new_autoload.load_carrier( + carrier_end_rail=carrier_end_rail, + carrier_barcode_reading=carrier_barcode_reading, + barcode_reading=barcode_reading, + barcode_reading_direction=barcode_reading_direction, + barcode_symbology=barcode_symbology, + no_container_per_carrier=no_container_per_carrier, + reading_position_of_first_barcode=reading_position_of_first_barcode, + distance_between_containers=distance_between_containers, + width_of_reading_window=width_of_reading_window, + reading_speed=reading_speed, + park_autoload_after=park_autoload_after, + ) async def set_loading_indicators(self, bit_pattern: List[bool], blink_pattern: List[bool]): - """Set loading indicators (LEDs) - - The docs here are a little weird because 2^54 < 7FFFFFFFFFFFFF. - - Args: - bit_pattern: On if True, off otherwise - blink_pattern: Blinking if True, steady otherwise - """ - - assert len(bit_pattern) == 54, "bit pattern must be length 54" - assert len(blink_pattern) == 54, "bit pattern must be length 54" - - def pattern2hex(pattern: List[bool]) -> str: - bit_string = "".join(["1" if x else "0" for x in pattern]) - return hex(int(bit_string, base=2))[2:].upper().zfill(14) - - bit_pattern_hex = pattern2hex(bit_pattern) - blink_pattern_hex = pattern2hex(blink_pattern) - - return await self.send_command( - module="C0", - command="CP", - cl=bit_pattern_hex, - cb=blink_pattern_hex, - ) + """Deprecated: use ``star.autoload.set_loading_indicators()``.""" + return await self._new_autoload.set_loading_indicators(bit_pattern, blink_pattern) async def verify_and_wait_for_carriers( self, check_interval: float = 1.0, ): - """Verify that carriers have been loaded at expected rail positions. - - This function checks if carriers are physically present on the deck at the specified - rail positions using the deck's presence sensors. If any carriers are missing, it will: - 1. Prompt the user to load the missing carriers - 2. Flash LEDs at the missing positions using set_loading_indicators - 3. Continue checking until all carriers are detected - - Args: - check_interval: Interval in seconds between presence checks (default: 1.0) - - Raises: - ValueError: If no carriers are found on the deck. - """ - # Extract carriers from deck children with start and end rail positions - carrier_rails: List[Tuple[int, int]] = [] # List of (start_rail, end_rail) tuples + """Deprecated: use ``star.autoload.verify_and_wait_for_carriers()``.""" + # Compute carrier rails from deck children (geometry stays in legacy). + carrier_rails: List[Tuple[int, int]] = [] for child in self.deck.children: if isinstance(child, Carrier): - # Get x coordinate relative to deck carrier_x = child.get_location_wrt(self.deck).x carrier_start_rail = rails_for_x_coordinate(carrier_x) carrier_end_rail = rails_for_x_coordinate(carrier_x - 100.0 + child.get_absolute_size_x()) - - # Verify rails are valid carrier_start_rail = max(1, min(carrier_start_rail, 54)) if 1 <= carrier_end_rail <= 54: carrier_rails.append((carrier_start_rail, carrier_end_rail)) - if len(carrier_rails) == 0: - raise ValueError("No carriers found on deck. Assign carriers to the deck.") - - # Extract end rails for comparison with detected rails - # The presence detection reports the end rail position - expected_end_rails = [end_rail for _, end_rail in carrier_rails] - - # Check initial presence - detected_rails = set(await self.request_presence_of_carriers_on_deck()) - missing_end_rails = sorted(set(expected_end_rails) - detected_rails) - - if len(missing_end_rails) == 0: - logger.info(f"All carriers detected at end rail positions: {expected_end_rails}") - # Turn off all indicators - await self.set_loading_indicators( - bit_pattern=[False] * 54, - blink_pattern=[False] * 54, - ) - print(f"\n✓ All carriers successfully detected at end rail positions: {expected_end_rails}\n") - return - - # Prompt user about missing carriers - print( - f"\n{'=' * 60}\n" - f"CARRIER LOADING REQUIRED\n" - f"{'=' * 60}\n" - f"Expected carriers at end rail positions: {expected_end_rails}\n" - f"Detected carriers at rail positions: {sorted(detected_rails)}\n" - f"Missing carriers at end rail positions: {missing_end_rails}\n" - f"{'=' * 60}\n" - f"Please load the missing carriers. LEDs will flash at the carrier positions.\n" - f"The system will automatically detect when all carriers are loaded.\n" - f"{'=' * 60}\n" - ) - - # Flash LEDs until all carriers are detected - while missing_end_rails: - # Create bit pattern for missing carriers - # Flash all LEDs from start_rail to end_rail (inclusive) for each missing carrier - bit_pattern = [False] * 54 - blink_pattern = [False] * 54 - - # For each missing carrier (identified by missing end rail), flash all its rails - for missing_end_rail in missing_end_rails: - # Find the carrier with this end rail - for start_rail, end_rail in carrier_rails: - if end_rail == missing_end_rail: - # Flash all LEDs from start_rail to end_rail (inclusive) - for rail in range(start_rail, end_rail + 1): - if 1 <= rail <= 54: - indicator_index = rail - 1 # Convert rail (1-54) to index (0-53) - bit_pattern[indicator_index] = True - blink_pattern[indicator_index] = True - break - - # Set loading indicators - await self.set_loading_indicators(bit_pattern[::-1], blink_pattern[::-1]) - - # Wait before checking again - await asyncio.sleep(check_interval) - - # Check for presence again - detected_rails = set(await self.request_presence_of_carriers_on_deck()) - missing_end_rails = sorted(set(expected_end_rails) - detected_rails) - - # All carriers detected, turn off all indicators - logger.info(f"All carriers successfully detected at end rail positions: {expected_end_rails}") - await self.set_loading_indicators( - bit_pattern=[False] * 54, - blink_pattern=[False] * 54, - ) - print("\n✓ All carriers successfully loaded and detected!\n") + return await self._new_autoload.verify_and_wait_for_carriers( + carrier_rails=carrier_rails, + check_interval=check_interval, + ) async def unload_carrier( self, carrier: Carrier, park_autoload_after: bool = True, ): - """Use autoload to unload carrier.""" - # Identify carrier end rail - track_width = 22.5 - carrier_width = carrier.get_location_wrt(self.deck).x - 100 + carrier.get_absolute_size_x() - carrier_end_rail = int(carrier_width / track_width) - - assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" - - carrier_end_rail_str = str(carrier_end_rail).zfill(2) - - # Unload - resp = await self.send_command( - module="C0", - command="CR", - cp=carrier_end_rail_str, + """Deprecated: use ``star.autoload.unload_carrier()``.""" + carrier_end_rail = self._compute_end_rail_of_carrier(carrier) + return await self._new_autoload.unload_carrier( + carrier_end_rail=carrier_end_rail, + park_autoload_after=park_autoload_after, ) - if park_autoload_after: - await self.park_autoload() - - return resp - # -------------- 3.14 G1-3/ CR Needle Washer commands -------------- # TODO: All needle washer commands @@ -9227,21 +8102,9 @@ async def unload_carrier( # -------------- 3.15 Pump unit commands -------------- async def request_pump_settings(self, pump_station: int = 1): - """Set carrier monitoring - - Args: - carrier_position: pump station number (1..3) - - Returns: - 0 = CoRe 96 wash station (single chamber) - 1 = DC wash station (single chamber rev 02 ) 2 = ReReRe (single chamber) - 3 = CoRe 96 wash station (dual chamber) - 4 = DC wash station (dual chamber) - 5 = ReReRe (dual chamber) - """ - + """Deprecated: use ``star.wash_station.request_settings()``.""" + # Legacy returned the raw send_command dict; preserve that contract. assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" - return await self.send_command(module="C0", command="ET", fmt="et#", ep=pump_station) # -------------- 3.15.1 DC Wash commands (only for revision up to 01) -------------- @@ -9263,15 +8126,8 @@ async def request_pump_settings(self, pump_station: int = 1): # -------------- 3.15.3 Dual chamber pump unit only -------------- async def initialize_dual_pump_station_valves(self, pump_station: int = 1): - """Initialize pump station valves (dual chamber only) - - Args: - carrier_position: pump station number (1..3) - """ - - assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" - - return await self.send_command(module="C0", command="EJ", ep=pump_station) + """Deprecated: use ``star.wash_station.initialize_valves()``.""" + return await self._new_wash_station.initialize_valves(station=pump_station) async def fill_selected_dual_chamber( self, @@ -9281,49 +8137,20 @@ async def fill_selected_dual_chamber( chamber: int = 2, waste_chamber_suck_time_after_sensor_change: int = 0, ): - """Initialize pump station valves (dual chamber only) - - Args: - carrier_position: pump station number (1..3) - drain_before_refill: drain chamber before refill. Default False. - wash_fluid: wash fluid (1 or 2) - chamber: chamber (1 or 2) - drain_before_refill: waste chamber suck time after sensor change [s] (for error handling only) - """ - - assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" - assert 1 <= wash_fluid <= 2, "wash_fluid must be between 1 and 2" - assert 1 <= chamber <= 2, "chamber must be between 1 and 2" - - # wash fluid <-> chamber connection - # 0 = wash fluid 1 <-> chamber 2 - # 1 = wash fluid 1 <-> chamber 1 - # 2 = wash fluid 2 <-> chamber 1 - # 3 = wash fluid 2 <-> chamber 2 - connection = {(1, 2): 0, (1, 1): 1, (2, 1): 2, (2, 2): 3}[wash_fluid, chamber] - - return await self.send_command( - module="C0", - command="EH", - ep=pump_station, - ed=drain_before_refill, - ek=connection, - eu=f"{waste_chamber_suck_time_after_sensor_change:02}", - wait=False, + """Deprecated: use ``star.wash_station.fill_chamber()``.""" + return await self._new_wash_station.fill_chamber( + station=pump_station, + drain_before_refill=drain_before_refill, + wash_fluid=wash_fluid, + chamber=chamber, + waste_chamber_suck_time_after_sensor_change=waste_chamber_suck_time_after_sensor_change, ) # TODO:(command:EK) Drain selected chamber async def drain_dual_chamber_system(self, pump_station: int = 1): - """Drain system (dual chamber only) - - Args: - carrier_position: pump station number (1..3) - """ - - assert 1 <= pump_station <= 3, "pump_station must be between 1 and 3" - - return await self.send_command(module="C0", command="EL", ep=pump_station) + """Deprecated: use ``star.wash_station.drain()``.""" + return await self._new_wash_station.drain(station=pump_station) # TODO:(command:QD) Request dual chamber pump station prime status @@ -10154,53 +8981,32 @@ async def request_iswap_version(self) -> str: # -------------- 3.18 Cover and port control -------------- async def lock_cover(self): - """Lock cover""" - - return await self.send_command(module="C0", command="CO") + """Deprecated: use ``star.cover.lock()``.""" + return await self._new_cover.lock() async def unlock_cover(self): - """Unlock cover""" - - return await self.send_command(module="C0", command="HO") + """Deprecated: use ``star.cover.unlock()``.""" + return await self._new_cover.unlock() async def disable_cover_control(self): - """Disable cover control""" - - return await self.send_command(module="C0", command="CD") + """Deprecated: use ``star.cover.disable()``.""" + return await self._new_cover.disable() async def enable_cover_control(self): - """Enable cover control""" - - return await self.send_command(module="C0", command="CE") - - async def set_cover_output(self, output: int = 0): - """Set cover output - - Args: - output: 1 = cover lock; 2 = reserve out; 3 = reserve out. - """ - - assert 1 <= output <= 3, "output must be between 1 and 3" - return await self.send_command(module="C0", command="OS", on=output) + """Deprecated: use ``star.cover.enable()``.""" + return await self._new_cover.enable() - async def reset_output(self, output: int = 0): - """Reset output - - Returns: - output: 1 = cover lock; 2 = reserve out; 3 = reserve out. - """ + async def set_cover_output(self, output: int = 1): + """Deprecated: use ``star.cover.set_output()``.""" + return await self._new_cover.set_output(output=output) - assert 1 <= output <= 3, "output must be between 1 and 3" - return await self.send_command(module="C0", command="QS", on=output, fmt="#") + async def reset_output(self, output: int = 1): + """Deprecated: use ``star.cover.reset_output()``.""" + return await self._new_cover.reset_output(output=output) async def request_cover_open(self) -> bool: - """Request cover open - - Returns: True if the cover is open - """ - - resp = await self.send_command(module="C0", command="QC", fmt="qc#") - return bool(resp["qc"]) + """Deprecated: use ``star.cover.is_open()``.""" + return await self._new_cover.is_open() # -------------- Extra - Probing labware with STAR - making STAR into a CMM -------------- From d364172493c64fde744b1310af0f8aab106d1c63 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 28 Mar 2026 21:27:37 -0700 Subject: [PATCH 21/69] Rename get_ to request_ for all device-reading methods across codebase These methods send a command to hardware and wait for a response, so request_ better reflects the I/O semantics. Covers PreciseFlex, capability interfaces (temperature, humidity), and all vendor backends (Azenta, Agilent, BMG, Byonoy, Hamilton, INHECO, Liconic, Molecular Devices, Opentrons, Qinstruments, Thermo Fisher). Legacy public APIs keep get_ names unchanged; only internal delegations are updated. Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/agilent/biotek/biotek.py | 18 ++-- pylabrobot/agilent/biotek/biotek_tests.py | 8 +- pylabrobot/agilent/vspin/vspin.py | 94 +++++++++---------- pylabrobot/arms/arm.py | 4 +- pylabrobot/arms/backend.py | 8 +- pylabrobot/azenta/a4s.py | 18 ++-- pylabrobot/azenta/xpeel.py | 8 +- .../clariostar/absorbance_backend.py | 2 +- pylabrobot/bmg_labtech/clariostar/driver.py | 2 +- .../clariostar/luminescence_backend.py | 2 +- pylabrobot/brooks/pf400_test.ipynb | 90 +++--------------- pylabrobot/brooks/precise_flex.py | 58 ++++++------ pylabrobot/byonoy/absorbance_96.py | 4 +- .../humidity_controlling/backend.py | 2 +- .../humidity_controller.py | 4 +- .../temperature_controlling/backend.py | 2 +- .../temperature_controller.py | 8 +- pylabrobot/hamilton/heater_shaker/backend.py | 14 +-- pylabrobot/hamilton/liquid_handlers/base.py | 2 +- .../hamilton/liquid_handlers/star/core.py | 4 +- .../liquid_handlers/star/head96_backend.py | 2 +- .../hamilton/liquid_handlers/star/iswap.py | 4 +- .../liquid_handlers/star/pip_backend.py | 2 +- pylabrobot/inheco/cpac.py | 4 +- pylabrobot/inheco/scila/scila_backend.py | 2 +- .../inheco/scila/scila_backend_tests.py | 4 +- pylabrobot/io/ftdi.py | 2 +- .../arms/precise_flex/precise_flex_backend.py | 52 +++++----- pylabrobot/legacy/centrifuge/vspin_backend.py | 16 ++-- .../heating_shaking/bioshake_backend.py | 2 +- .../heating_shaking/hamilton_backend.py | 8 +- .../inheco/thermoshake_backend.py | 4 +- .../molecular_devices/pico/backend.py | 6 +- .../molecular_devices/pico/backend_tests.py | 6 +- pylabrobot/legacy/peeling/xpeel_backend.py | 8 +- .../plate_reading/agilent/biotek_backend.py | 6 +- pylabrobot/legacy/sealing/a4s_backend.py | 6 +- pylabrobot/legacy/storage/cytomat/cytomat.py | 22 ++--- .../cytomat/heraeus_cytomat_backend.py | 2 +- .../storage/inheco/scila/scila_backend.py | 2 +- .../legacy/storage/liconic/liconic_backend.py | 18 ++-- .../inheco/temperature_controller.py | 4 +- .../opentrons_backend.py | 2 +- .../opentrons_backend_usb.py | 2 +- .../temperature_controller.py | 2 +- pylabrobot/liconic/backend.py | 18 ++-- .../imageXpress/pico/backend.py | 18 ++-- .../molecular_devices/spectramax/backend.py | 12 +-- .../temperature_module/http_driver.py | 2 +- .../temperature_module/usb_driver.py | 2 +- pylabrobot/qinstruments/bioshake.py | 2 +- pylabrobot/thermo_fisher/cytomat/backend.py | 48 +++++----- .../thermo_fisher/cytomat/chatterbox.py | 2 +- .../thermo_fisher/cytomat/heraeus_backend.py | 4 +- 54 files changed, 290 insertions(+), 358 deletions(-) diff --git a/pylabrobot/agilent/biotek/biotek.py b/pylabrobot/agilent/biotek/biotek.py index 4b727b28de9..fac9c935df4 100644 --- a/pylabrobot/agilent/biotek/biotek.py +++ b/pylabrobot/agilent/biotek/biotek.py @@ -84,10 +84,10 @@ async def setup(self) -> None: await self.io.set_rts(True) try: - self._version = await self.get_firmware_version() + self._version = await self.request_firmware_version() except TimeoutError: await self.io.set_baudrate(38_461) - self._version = await self.get_firmware_version() + self._version = await self.request_firmware_version() self._shaking = False self._shaking_task: Optional[asyncio.Task] = None @@ -185,12 +185,12 @@ async def send_command( return response - async def get_serial_number(self) -> str: + async def request_serial_number(self) -> str: resp = await self.send_command("C", timeout=1) assert resp is not None return resp[1:].split(b" ")[0].decode() - async def get_firmware_version(self) -> str: + async def request_firmware_version(self) -> str: resp = await self.send_command("e", timeout=1) assert resp is not None return " ".join(resp[1:-1].decode().split(" ")[3:4]) @@ -215,7 +215,7 @@ async def close(self, plate: Optional[Plate] = None, slow: bool = False): async def home(self): return await self.send_command("i", "x") - async def get_current_temperature(self) -> float: + async def request_current_temperature(self) -> float: resp = await self.send_command("h", timeout=1) assert resp is not None return int(resp[1:-1]) / 100000 @@ -225,7 +225,7 @@ async def set_temperature(self, temperature: float): raise NotImplementedError(f"{self.__class__.__name__} does not support temperature control.") tmin, tmax = self.temperature_range - current_temperature = await self.get_current_temperature() + current_temperature = await self.request_current_temperature() if (tmin is not None and temperature < tmin) or (tmax is not None and temperature > tmax): raise ValueError( @@ -357,7 +357,7 @@ async def read_absorbance( all_data[r][c] = parsed_data[r][c] try: - temp = await self.get_current_temperature() + temp = await self.request_current_temperature() except TimeoutError: temp = None @@ -425,7 +425,7 @@ async def read_luminescence( all_data[r][c] = parsed_data[r][c] try: - temp = await self.get_current_temperature() + temp = await self.request_current_temperature() except TimeoutError: temp = None @@ -492,7 +492,7 @@ async def read_fluorescence( all_data[r][c] = parsed_data[r][c] try: - temp = await self.get_current_temperature() + temp = await self.request_current_temperature() except TimeoutError: temp = None diff --git a/pylabrobot/agilent/biotek/biotek_tests.py b/pylabrobot/agilent/biotek/biotek_tests.py index e58aeeafe49..a38fa3cc11f 100644 --- a/pylabrobot/agilent/biotek/biotek_tests.py +++ b/pylabrobot/agilent/biotek/biotek_tests.py @@ -59,9 +59,9 @@ async def test_setup(self): await self.backend.stop() assert self.backend.io.stop.called - async def test_get_serial_number(self): + async def test_request_serial_number(self): self.backend.io.read.side_effect = _byte_iter("\x0600000000 0000\x03") - assert await self.backend.get_serial_number() == "00000000" + assert await self.backend.request_serial_number() == "00000000" async def test_open(self): self.backend.io.read.side_effect = [b"\x06", b"\x03", b"\x03"] @@ -74,9 +74,9 @@ async def test_close(self): await self.backend.close(plate=plate) self.backend.io.write.assert_called_with(b"A") - async def test_get_current_temperature(self): + async def test_request_current_temperature(self): self.backend.io.read.side_effect = _byte_iter("\x062360000\x03") - assert await self.backend.get_current_temperature() == 23.6 + assert await self.backend.request_current_temperature() == 23.6 async def test_read_absorbance(self): self.backend.io.read.side_effect = _byte_iter( diff --git a/pylabrobot/agilent/vspin/vspin.py b/pylabrobot/agilent/vspin/vspin.py index d2db1114292..49f973cb39f 100644 --- a/pylabrobot/agilent/vspin/vspin.py +++ b/pylabrobot/agilent/vspin/vspin.py @@ -168,40 +168,40 @@ class _StatusPositionTachometer(ctypes.LittleEndianStructure): ("checksum", ctypes.c_uint8), ] - async def get_positions_and_tachometer(self) -> _StatusPositionTachometer: + async def request_positions_and_tachometer(self) -> _StatusPositionTachometer: resp = await self.send_command(bytes.fromhex("aa010e0f")) if len(resp) == 0: raise IOError("Empty status from centrifuge") return VSpinDriver._StatusPositionTachometer.from_buffer_copy(resp) - async def get_position(self) -> int: - return (await self.get_positions_and_tachometer()).current_position # type: ignore + async def request_position(self) -> int: + return (await self.request_positions_and_tachometer()).current_position # type: ignore - async def get_tachometer(self) -> int: + async def request_tachometer(self) -> int: """Current speed in rpm.""" tack_to_rpm = -14.69320388 - return (await self.get_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore + return (await self.request_positions_and_tachometer()).tachometer * tack_to_rpm # type: ignore - async def get_home_position(self) -> int: + async def request_home_position(self) -> int: """Changes during a run, but the bucket 1 position relative to it does not.""" - return (await self.get_positions_and_tachometer()).home_position # type: ignore + return (await self.request_positions_and_tachometer()).home_position # type: ignore - async def _get_status(self): + async def _request_status(self): resp = await self.send_command(bytes.fromhex("aa020e10")) if len(resp) == 0: raise IOError("Empty status from centrifuge. Is the machine on?") return resp - async def get_bucket_locked(self) -> bool: - resp = await self._get_status() + async def request_bucket_locked(self) -> bool: + resp = await self._request_status() return resp[2] & 0b0001 != 0 # type: ignore - async def get_door_open(self) -> bool: - resp = await self._get_status() + async def request_door_open(self) -> bool: + resp = await self._request_status() return resp[2] & 0b0010 != 0 # type: ignore - async def get_door_locked(self) -> bool: - resp = await self._get_status() + async def request_door_locked(self) -> bool: + resp = await self._request_status() return resp[2] & 0b0100 == 0 # type: ignore @@ -252,7 +252,7 @@ async def _on_setup(self): resp = 0x89 while resp == 0x89: - resp = (await driver.get_positions_and_tachometer()).status + resp = (await driver.request_positions_and_tachometer()).status # --- almost the same as go to position --- await driver.send_command(bytes.fromhex("aa0117021a")) @@ -270,14 +270,14 @@ async def _on_setup(self): resp = 0x08 while resp != 0x09: - resp = (await driver.get_positions_and_tachometer()).status + resp = (await driver.request_positions_and_tachometer()).status await driver.send_command(bytes.fromhex("aa0117021a")) await self.lock_door() if self._bucket_1_remainder is None: - device_id = await driver.io.get_serial() + device_id = await driver.io.request_serial() self._bucket_1_remainder = _load_vspin_calibrations(device_id) # -- bucket calibration -- @@ -290,19 +290,19 @@ def bucket_1_remainder(self) -> int: async def set_bucket_1_position_to_current(self) -> None: """Set the current position as bucket 1 position and save calibration.""" - current_position = await self._driver.get_position() - device_id = await self._driver.io.get_serial() - remainder = await self._driver.get_home_position() - current_position + current_position = await self._driver.request_position() + device_id = await self._driver.io.request_serial() + remainder = await self._driver.request_home_position() - current_position self._bucket_1_remainder = current_position % FULL_ROTATION _save_vspin_calibrations(device_id, remainder) - async def get_bucket_1_position(self) -> int: + async def request_bucket_1_position(self) -> int: """Get the bucket 1 position based on calibration.""" if self._bucket_1_remainder is None: raise bucket_1_not_set_error - home_position = await self._driver.get_home_position() + home_position = await self._driver.request_home_position() bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder - current_position = await self._driver.get_position() + current_position = await self._driver.request_position() bucket_1_position = ( FULL_ROTATION * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) @@ -313,44 +313,44 @@ async def get_bucket_1_position(self) -> int: # -- CentrifugeBackend interface -- async def open_door(self): - if await self._driver.get_door_open(): + if await self._driver.request_door_open(): return await self._driver.send_command(bytes.fromhex("aa022600062e")) await asyncio.sleep(4) async def close_door(self): - if not (await self._driver.get_door_open()): + if not (await self._driver.request_door_open()): return await self._driver.send_command(bytes.fromhex("aa022600042c")) await asyncio.sleep(2) async def lock_door(self): - if await self._driver.get_door_open(): + if await self._driver.request_door_open(): raise RuntimeError("Cannot lock door while it is open.") - if await self._driver.get_door_locked(): + if await self._driver.request_door_locked(): return await self._driver.send_command(bytes.fromhex("aa0226000028")) async def unlock_door(self): - if not await self._driver.get_door_locked(): + if not await self._driver.request_door_locked(): return await self._driver.send_command(bytes.fromhex("aa022600042c")) async def lock_bucket(self): - if await self._driver.get_bucket_locked(): + if await self._driver.request_bucket_locked(): return await self._driver.send_command(bytes.fromhex("aa022600072f")) async def unlock_bucket(self): - if not await self._driver.get_bucket_locked(): + if not await self._driver.request_bucket_locked(): return await self._driver.send_command(bytes.fromhex("aa022600062e")) async def go_to_bucket1(self): - await self.go_to_position(await self.get_bucket_1_position()) + await self.go_to_position(await self.request_bucket_1_position()) async def go_to_bucket2(self): - await self.go_to_position(await self.get_bucket_1_position() + FULL_ROTATION // 2) + await self.go_to_position(await self.request_bucket_1_position() + FULL_ROTATION // 2) async def go_to_position(self, position: int): await self.close_door() @@ -369,7 +369,7 @@ async def go_to_position(self, position: int): await self._driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) await self._driver.send_command(byte_string) - while abs(await self._driver.get_position() - position) > 10: + while abs(await self._driver.request_position() - position) > 10: await asyncio.sleep(0.1) await self.open_door() @@ -412,11 +412,11 @@ async def spin( if duration < 1: raise ValueError("Spin time must be at least 1 second") - if await self._driver.get_door_open(): + if await self._driver.request_door_open(): await self.close_door() - if not await self._driver.get_door_locked(): + if not await self._driver.request_door_locked(): await self.lock_door() - if await self._driver.get_bucket_locked(): + if await self._driver.request_bucket_locked(): await self.unlock_bucket() rpm = VSpinCentrifugeBackend.g_to_rpm(g) @@ -428,7 +428,7 @@ async def spin( distance_at_speed = ticks_per_second * duration - current_position = await self._driver.get_position() + current_position = await self._driver.request_position() final_position = int(current_position + distance_during_acceleration + distance_at_speed) if final_position > 2**32 - 1: @@ -456,15 +456,15 @@ async def spin( await self._driver.send_command(byte_string) while ( - await self._driver.get_tachometer() < rpm * 0.95 - and await self._driver.get_position() < final_position + await self._driver.request_tachometer() < rpm * 0.95 + and await self._driver.request_position() < final_position ): await asyncio.sleep(0.1) - if await self._driver.get_position() < final_position: - decel_start_position = await self._driver.get_position() + distance_at_speed + if await self._driver.request_position() < final_position: + decel_start_position = await self._driver.request_position() + distance_at_speed - while await self._driver.get_position() < decel_start_position: + while await self._driver.request_position() < decel_start_position: await asyncio.sleep(0.1) await self._driver.send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) @@ -488,9 +488,9 @@ async def _reset_to_zero(): await _reset_to_zero() - start = await self._driver.get_home_position() + start = await self._driver.request_home_position() num_tries = 0 - while await self._driver.get_home_position() == start: + while await self._driver.request_home_position() == start: await asyncio.sleep(0.1) num_tries += 1 if num_tries % 25 == 0: @@ -541,7 +541,7 @@ async def setup(self): await self.io.setup() await self.io.set_baudrate(115384) - status = await self.get_status() + status = await self.request_status() if not status.startswith(bytes.fromhex("1105")): raise RuntimeError("Failed to get status") @@ -560,8 +560,8 @@ async def stop(self): logger.debug("[loader] stop") await self.io.stop() - async def get_status(self) -> bytes: - logger.debug("[loader] get_status") + async def request_status(self) -> bytes: + logger.debug("[loader] request_status") return await self.send_command(bytes.fromhex("11050003002000006bd4")) async def park(self): diff --git a/pylabrobot/arms/arm.py b/pylabrobot/arms/arm.py index 453e98ea510..ea37a709a95 100644 --- a/pylabrobot/arms/arm.py +++ b/pylabrobot/arms/arm.py @@ -72,11 +72,11 @@ async def park(self, backend_params: Optional[BackendParams] = None) -> None: """Park the arm to its default position.""" return await self.backend.park(backend_params=backend_params) - async def get_gripper_location( + async def request_gripper_location( self, backend_params: Optional[BackendParams] = None ) -> GripperLocation: """Get the current location and rotation of the gripper.""" - return await self.backend.get_gripper_location(backend_params=backend_params) + return await self.backend.request_gripper_location(backend_params=backend_params) # -- holding state ----------------------------------------------------------- diff --git a/pylabrobot/arms/backend.py b/pylabrobot/arms/backend.py index 678c021ff92..e47464f2070 100644 --- a/pylabrobot/arms/backend.py +++ b/pylabrobot/arms/backend.py @@ -10,7 +10,7 @@ # - pick_up_at_location # - drop_at_location # - move_to_location -# - get_gripper_location +# - request_gripper_location # - is_holding_resource # CanGrip @@ -29,7 +29,7 @@ # Joints # - pick_up_at_joint_position # - drop_at_joint_position -# - get_joint_position +# - request_joint_position class CanFreedrive(metaclass=ABCMeta): @@ -78,7 +78,7 @@ async def move_to_joint_position( """Move the arm to the specified joint position.""" @abstractmethod - async def get_joint_position( + async def request_joint_position( self, backend_params: Optional[BackendParams] = None ) -> Dict[int, float]: """Get the current position of the arm in joint space.""" @@ -117,7 +117,7 @@ async def park(self, backend_params: Optional[BackendParams] = None) -> None: """Park the arm to its default position.""" @abstractmethod - async def get_gripper_location( + async def request_gripper_location( self, backend_params: Optional[BackendParams] = None ) -> GripperLocation: """Get the current location and rotation of the gripper.""" diff --git a/pylabrobot/azenta/a4s.py b/pylabrobot/azenta/a4s.py index ac324784bd0..4240c2bf783 100644 --- a/pylabrobot/azenta/a4s.py +++ b/pylabrobot/azenta/a4s.py @@ -120,7 +120,7 @@ async def read_message(self) -> str: # -- status -- - async def get_status(self) -> A4SStatus: + async def request_status(self) -> A4SStatus: while True: message = await self.read_message() if message[1] == "T": @@ -156,7 +156,7 @@ async def get_status(self) -> A4SStatus: async def wait_for_status(self, statuses: Set[A4SStatus.SystemStatus]) -> A4SStatus: start = time.time() while True: - status = await self.get_status() + status = await self.request_status() if status.system_status == A4SStatus.SystemStatus.error: raise RuntimeError(f"An error occurred: {status.error_code}") @@ -174,7 +174,7 @@ async def wait_for_shuttle_open_sensor( ) -> A4SStatus: start = time.time() while True: - status = await self.get_status() + status = await self.request_status() if status.sensor_status.shuttle_open_sensor == shuttle_open: return status if time.time() - start > timeout: @@ -196,8 +196,8 @@ async def set_time(self, seconds: float): command = f"*00DT={deciseconds:04d}zz!" return await self.send_command(command) - async def get_remaining_time(self) -> int: - status = await self.get_status() + async def request_remaining_time(self) -> int: + status = await self.request_status() return status.remaining_time def serialize(self) -> dict: @@ -231,7 +231,7 @@ async def close(self): async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: float = 0.5): start = time.time() while True: - status = await self._driver.get_status() + status = await self._driver.request_status() if abs(status.current_temperature - degrees) < tolerance: break if time.time() - start > timeout: @@ -259,15 +259,15 @@ async def set_temperature(self, temperature: float): async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: float = 0.5): start = time.time() while True: - current_temperature = await self.get_current_temperature() + current_temperature = await self.request_current_temperature() if abs(current_temperature - degrees) < tolerance: break if time.time() - start > timeout: raise TimeoutError("Timeout while waiting for temperature") await asyncio.sleep(0.1) - async def get_current_temperature(self) -> float: - status = await self._driver.get_status() + async def request_current_temperature(self) -> float: + status = await self._driver.request_status() return status.current_temperature async def deactivate(self): diff --git a/pylabrobot/azenta/xpeel.py b/pylabrobot/azenta/xpeel.py index e164d6d37bd..e67fd37fba8 100644 --- a/pylabrobot/azenta/xpeel.py +++ b/pylabrobot/azenta/xpeel.py @@ -146,12 +146,12 @@ async def send_command( return responses - async def get_status(self) -> Tuple[int, int, int]: + async def request_status(self) -> Tuple[int, int, int]: """Request instrument status; returns three error codes.""" resp = await self.send_command("*stat") return tuple([int(x) for x in resp[-1].split(":")[1].split(",")]) # type: ignore - async def get_version(self): + async def request_version(self): """Request firmware version.""" return await self.send_command("*version") @@ -177,7 +177,7 @@ async def seal_check(self) -> Literal["seal_detected", "no_seal", "plate_not_det f"Unexpected seal check code: {code}, interpreted as: {self.describe_error(code)}" ) - async def get_tape_remaining(self): + async def request_tape_remaining(self): """Query remaining tape. Returns (supply_remaining, takeup_remaining) in number of deseals.""" resp = await self.send_command("*tapeleft", expect_ack=True, wait_for_ready=True) tape_line = resp[-1] @@ -191,7 +191,7 @@ async def enable_plate_check(self, enabled=True): flag = "y" if enabled else "n" return await self.send_command(f"*platecheck:{flag}", expect_ack=True, wait_for_ready=True) - async def get_seal_sensor_status(self): + async def request_seal_sensor_status(self): """Get seal sensor threshold value (0-999).""" return await self.send_command("*sealstat", expect_ack=True, wait_for_ready=True) diff --git a/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py b/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py index dd3b926c0a6..3c73e045260 100644 --- a/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py +++ b/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py @@ -62,7 +62,7 @@ async def read_absorbance( await self._driver.read_order_values() await self._driver.status_hw() - vals = await self._driver.get_measurement_values() + vals = await self._driver.request_measurement_values() num_wells = plate.num_items div = b"\x00" * 6 start_idx = vals.index(div) + len(div) diff --git a/pylabrobot/bmg_labtech/clariostar/driver.py b/pylabrobot/bmg_labtech/clariostar/driver.py index d574c644125..45b12d589f2 100644 --- a/pylabrobot/bmg_labtech/clariostar/driver.py +++ b/pylabrobot/bmg_labtech/clariostar/driver.py @@ -141,7 +141,7 @@ async def status_hw(self) -> bytes: resp = await self.send(b"\x02\x00\x09\x0c\x81\x00") return await self._wait_for_ready_and_return(resp) - async def get_measurement_values(self) -> bytes: + async def request_measurement_values(self) -> bytes: return await self.send(b"\x02\x00\x0f\x0c\x05\x02\x00\x00\x00\x00\x00\x00") def plate_bytes(self, plate: Plate) -> bytes: diff --git a/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py b/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py index 6956b4b8cf3..a99cafca776 100644 --- a/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py +++ b/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py @@ -45,7 +45,7 @@ async def read_luminescence( await self._driver.read_order_values() await self._driver.status_hw() - vals = await self._driver.get_measurement_values() + vals = await self._driver.request_measurement_values() num_wells = plate.num_items start_idx = vals.index(b"\x00\x00\x00\x00\x00\x00") + len(b"\x00\x00\x00\x00\x00\x00") data = list(vals)[start_idx : start_idx + num_wells * 4] diff --git a/pylabrobot/brooks/pf400_test.ipynb b/pylabrobot/brooks/pf400_test.ipynb index e061137d6d1..ae984684660 100644 --- a/pylabrobot/brooks/pf400_test.ipynb +++ b/pylabrobot/brooks/pf400_test.ipynb @@ -88,14 +88,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "backend = PreciseFlex400Backend(host=\"192.168.0.1\")\n", - "reference = Resource(\"workcell\", size_x=2000, size_y=2000, size_z=0)\n", - "arm = JointArm(backend=backend, reference_resource=reference)\n", - "\n", - "await arm.setup()\n", - "print(f\"Version: {await backend.get_version()}\")" - ] + "source": "backend = PreciseFlex400Backend(host=\"192.168.0.1\")\nreference = Resource(\"workcell\", size_x=2000, size_y=2000, size_z=0)\narm = JointArm(backend=backend, reference_resource=reference)\n\nawait arm.setup()\nprint(f\"Version: {await backend.request_version()}\")" }, { "cell_type": "markdown", @@ -109,17 +102,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "cart = await arm.get_cartesian_position()\n", - "print(f\"Cartesian: x={cart.location.x:.1f}, y={cart.location.y:.1f}, z={cart.location.z:.1f}\")\n", - "print(\n", - " f\"Rotation: yaw={cart.rotation.z:.1f}, pitch={cart.rotation.y:.1f}, roll={cart.rotation.x:.1f}\"\n", - ")\n", - "print(f\"Elbow: {cart.orientation}\")\n", - "print()\n", - "joints = await arm.get_joint_position()\n", - "print(f\"Joints: {joints}\")" - ] + "source": "cart = await arm.request_gripper_location()\nprint(f\"Cartesian: x={cart.location.x:.1f}, y={cart.location.y:.1f}, z={cart.location.z:.1f}\")\nprint(\n f\"Rotation: yaw={cart.rotation.z:.1f}, pitch={cart.rotation.y:.1f}, roll={cart.rotation.x:.1f}\"\n)\nprint(f\"Elbow: {cart.orientation}\")\nprint()\njoints = await arm.request_joint_position()\nprint(f\"Joints: {joints}\")" }, { "cell_type": "markdown", @@ -145,14 +128,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "# Read position while in freedrive\n", - "cart = await arm.get_cartesian_position()\n", - "print(\n", - " f\"x={cart.location.x:.1f}, y={cart.location.y:.1f}, z={cart.location.z:.1f}, \"\n", - " f\"yaw={cart.rotation.z:.1f}, orientation={cart.orientation}\"\n", - ")" - ] + "source": "# Read position while in freedrive\ncart = await arm.request_gripper_location()\nprint(\n f\"x={cart.location.x:.1f}, y={cart.location.y:.1f}, z={cart.location.z:.1f}, \"\n f\"yaw={cart.rotation.z:.1f}, orientation={cart.orientation}\"\n)" }, { "cell_type": "code", @@ -178,22 +154,14 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "# Save current position with a name\n", - "pos = await arm.get_cartesian_position()\n", - "save_position(\"home\", pos)" - ] + "source": "# Save current position with a name\npos = await arm.request_gripper_location()\nsave_position(\"home\", pos)" }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "# Save another position\n", - "pos = await arm.get_cartesian_position()\n", - "save_position(\"plate_pickup\", pos)" - ] + "source": "# Save another position\npos = await arm.request_gripper_location()\nsave_position(\"plate_pickup\", pos)" }, { "cell_type": "code", @@ -253,14 +221,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "await arm.move_to_location(\n", - " location=Coordinate(300, 0, 200),\n", - " direction=Rotation(0, 0, 0),\n", - " backend_params=PreciseFlexBackend.MoveToLocationParams(speed=20),\n", - ")\n", - "print(await arm.get_cartesian_position())" - ] + "source": "await arm.move_to_location(\n location=Coordinate(300, 0, 200),\n direction=Rotation(0, 0, 0),\n backend_params=PreciseFlexBackend.MoveToLocationParams(speed=20),\n)\nprint(await arm.request_gripper_location())" }, { "cell_type": "markdown", @@ -276,44 +237,21 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "await arm.move_to_location(\n", - " location=Coordinate(300, 0, 200),\n", - " direction=Rotation(0, 0, 0),\n", - " backend_params=PreciseFlexBackend.MoveToLocationParams(\n", - " orientation=ElbowOrientation.RIGHT,\n", - " speed=30,\n", - " ),\n", - ")\n", - "print(\"Righty:\", await arm.get_cartesian_position())" - ] + "source": "await arm.move_to_location(\n location=Coordinate(300, 0, 200),\n direction=Rotation(0, 0, 0),\n backend_params=PreciseFlexBackend.MoveToLocationParams(\n orientation=ElbowOrientation.RIGHT,\n speed=30,\n ),\n)\nprint(\"Righty:\", await arm.request_gripper_location())" }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "await arm.move_to_location(\n", - " location=Coordinate(300, 0, 200),\n", - " direction=Rotation(0, 0, 0),\n", - " backend_params=PreciseFlexBackend.MoveToLocationParams(\n", - " orientation=ElbowOrientation.LEFT,\n", - " speed=30,\n", - " ),\n", - ")\n", - "print(\"Lefty:\", await arm.get_cartesian_position())" - ] + "source": "await arm.move_to_location(\n location=Coordinate(300, 0, 200),\n direction=Rotation(0, 0, 0),\n backend_params=PreciseFlexBackend.MoveToLocationParams(\n orientation=ElbowOrientation.LEFT,\n speed=30,\n ),\n)\nprint(\"Lefty:\", await arm.request_gripper_location())" }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "await backend.change_config()\n", - "print(\"Flipped:\", await arm.get_cartesian_position())" - ] + "source": "await backend.change_config()\nprint(\"Flipped:\", await arm.request_gripper_location())" }, { "cell_type": "markdown", @@ -327,13 +265,7 @@ "execution_count": null, "metadata": {}, "outputs": [], - "source": [ - "await arm.move_to_joint_position(\n", - " {PFAxis.BASE: 45.0},\n", - " backend_params=PreciseFlexBackend.MoveToJointPositionParams(speed=30),\n", - ")\n", - "print(await arm.get_joint_position())" - ] + "source": "await arm.move_to_joint_position(\n {PFAxis.BASE: 45.0},\n backend_params=PreciseFlexBackend.MoveToJointPositionParams(speed=30),\n)\nprint(await arm.request_joint_position())" }, { "cell_type": "markdown", @@ -394,4 +326,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file diff --git a/pylabrobot/brooks/precise_flex.py b/pylabrobot/brooks/precise_flex.py index f4914e63c95..0e2c1bd32e2 100644 --- a/pylabrobot/brooks/precise_flex.py +++ b/pylabrobot/brooks/precise_flex.py @@ -175,7 +175,7 @@ async def exit(self) -> None: ResponseMode = Literal["pc", "verbose"] - async def get_mode(self) -> ResponseMode: + async def request_mode(self) -> ResponseMode: """Get the current response mode. Returns: @@ -241,7 +241,7 @@ async def set_power(self, enable: bool, timeout: int = 0) -> None: else: await self.send_command(f"hp {power_state} {timeout}") - async def get_power_state(self) -> int: + async def request_power_state(self) -> int: """Get the current robot power state. Returns: @@ -388,9 +388,9 @@ async def _set_speed(self, speed_percent: float): """Set the speed percentage of the arm's movement (0-100).""" await self.set_profile_speed(self.profile_index, speed_percent) - async def _get_speed(self) -> float: + async def _request_speed(self) -> float: """Get the current speed percentage of the arm's movement.""" - return await self.get_profile_speed(self.profile_index) + return await self.request_profile_speed(self.profile_index) async def open_gripper( self, gripper_width: float, backend_params: Optional[BackendParams] = None @@ -528,11 +528,11 @@ async def move_to_joint_position( backend_params = PreciseFlexArmBackend.MoveToJointPositionParams() if backend_params.speed is not None: await self._set_speed(backend_params.speed) - current = await self.get_joint_position() + current = await self.request_joint_position() joint_coords = {**current, **position} await self._move_j(profile_index=self.profile_index, joint_coords=joint_coords) - async def get_joint_position( + async def request_joint_position( self, backend_params: Optional[BackendParams] = None ) -> Dict[int, float]: """Get the current joint position of the arm.""" @@ -547,7 +547,7 @@ async def get_joint_position( raise PreciseFlexError(-1, "Unexpected response format from wherej command.") return self._parse_angles_response(parts) - async def get_gripper_location( + async def request_gripper_location( self, backend_params: Optional[BackendParams] = None ) -> PreciseFlexGripperLocation: """Get the current position of the arm in Cartesian space.""" @@ -563,7 +563,7 @@ async def get_gripper_location( x, y, z, yaw, pitch, roll = self._parse_xyz_response(parts[0:6]) config = int(parts[6]) elbow_orientation = self._convert_orientation_int_to_str(config) - rail_position = (await self.get_joint_position())[PFAxis.RAIL] if self._has_rail else None + rail_position = (await self.request_joint_position())[PFAxis.RAIL] if self._has_rail else None return PreciseFlexGripperLocation( location=Coordinate(x, y, z), @@ -808,7 +808,7 @@ async def _set_grip_detail(self, access: AccessPattern): # -- GENERAL COMMANDS ------------------------------------------------------ - async def get_base(self) -> tuple[float, float, float, float]: + async def request_base(self) -> tuple[float, float, float, float]: """Get the robot base offset. Returns: @@ -837,7 +837,7 @@ async def set_base( """ await self._driver.send_command(f"base {x_offset} {y_offset} {z_offset} {z_rotation}") - async def get_monitor_speed(self) -> int: + async def request_monitor_speed(self) -> int: """Get the global system (monitor) speed. Returns: @@ -867,7 +867,7 @@ async def nop(self) -> None: """ await self._driver.send_command("nop") - async def get_payload(self) -> int: + async def request_payload(self) -> int: """Get the payload percent value for the current robot. Returns: @@ -928,7 +928,7 @@ async def set_parameter( else: await self._driver.send_command(f"pc {data_id} {value}") - async def get_parameter( + async def request_parameter( self, data_id: int, unit_number: Optional[int] = None, @@ -976,7 +976,7 @@ async def reset(self, robot_number: int) -> None: raise ValueError("Robot number must be greater than zero") await self._driver.send_command(f"reset {robot_number}") - async def get_selected_robot(self) -> int: + async def request_selected_robot(self) -> int: """Get the number of the currently selected robot. Returns: @@ -997,7 +997,7 @@ async def select_robot(self, robot_number: int) -> None: """ await self._driver.send_command(f"selectRobot {robot_number}") - async def get_signal(self, signal_number: int) -> int: + async def request_signal(self, signal_number: int) -> int: """Get the value of the specified digital input or output signal. Args: @@ -1019,7 +1019,7 @@ async def set_signal(self, signal_number: int, value: int) -> None: """ await self._driver.send_command(f"sig {signal_number} {value}") - async def get_system_state(self) -> int: + async def request_system_state(self) -> int: """Get the global system state code. Returns: @@ -1028,7 +1028,7 @@ async def get_system_state(self) -> int: response = await self._driver.send_command("sysState") return int(response) - async def get_tool_transformation_values(self) -> tuple[float, float, float, float, float, float]: + async def request_tool_transformation_values(self) -> tuple[float, float, float, float, float, float]: """Get the current tool transformation values. Returns: @@ -1060,7 +1060,7 @@ async def set_tool_transformation_values( """ await self._driver.send_command(f"tool {x} {y} {z} {yaw} {pitch} {roll}") - async def get_version(self) -> str: + async def request_version(self) -> str: """Get the current version of TCS and any installed plug-ins. Returns: @@ -1225,7 +1225,7 @@ async def here_c(self, location_index: int) -> None: # -- PROFILE COMMANDS ------------------------------------------------------ - async def get_profile_speed(self, profile_index: int) -> float: + async def request_profile_speed(self, profile_index: int) -> float: """Get the speed property of the specified profile. Args: @@ -1248,7 +1248,7 @@ async def set_profile_speed(self, profile_index: int, speed_percent: float) -> N """ await self._driver.send_command(f"Speed {profile_index} {speed_percent}") - async def get_profile_speed2(self, profile_index: int) -> float: + async def request_profile_speed2(self, profile_index: int) -> float: """Get the speed2 property of the specified profile. Args: @@ -1271,7 +1271,7 @@ async def set_profile_speed2(self, profile_index: int, speed2_percent: float) -> """ await self._driver.send_command(f"Speed2 {profile_index} {speed2_percent}") - async def get_profile_accel(self, profile_index: int) -> float: + async def request_profile_accel(self, profile_index: int) -> float: """Get the acceleration property of the specified profile. Args: @@ -1294,7 +1294,7 @@ async def set_profile_accel(self, profile_index: int, accel_percent: float) -> N """ await self._driver.send_command(f"Accel {profile_index} {accel_percent}") - async def get_profile_accel_ramp(self, profile_index: int) -> float: + async def request_profile_accel_ramp(self, profile_index: int) -> float: """Get the acceleration ramp property of the specified profile. Args: @@ -1316,7 +1316,7 @@ async def set_profile_accel_ramp(self, profile_index: int, accel_ramp_seconds: f """ await self._driver.send_command(f"AccRamp {profile_index} {accel_ramp_seconds}") - async def get_profile_decel(self, profile_index: int) -> float: + async def request_profile_decel(self, profile_index: int) -> float: """Get the deceleration property of the specified profile. Args: @@ -1339,7 +1339,7 @@ async def set_profile_decel(self, profile_index: int, decel_percent: float) -> N """ await self._driver.send_command(f"Decel {profile_index} {decel_percent}") - async def get_profile_decel_ramp(self, profile_index: int) -> float: + async def request_profile_decel_ramp(self, profile_index: int) -> float: """Get the deceleration ramp property of the specified profile. Args: @@ -1361,7 +1361,7 @@ async def set_profile_decel_ramp(self, profile_index: int, decel_ramp_seconds: f """ await self._driver.send_command(f"DecRamp {profile_index} {decel_ramp_seconds}") - async def get_profile_in_range(self, profile_index: int) -> float: + async def request_profile_in_range(self, profile_index: int) -> float: """Get the InRange property of the specified profile. Args: @@ -1394,7 +1394,7 @@ async def set_profile_in_range(self, profile_index: int, in_range_value: float) raise ValueError("InRange value must be between -1 and 100") await self._driver.send_command(f"InRange {profile_index} {in_range_value}") - async def get_profile_straight(self, profile_index: int) -> bool: + async def request_profile_straight(self, profile_index: int) -> bool: """Get the Straight property of the specified profile. Args: @@ -1470,7 +1470,7 @@ async def set_motion_profile_values( f"{acceleration_ramp} {deceleration_ramp} {in_range} {straight_int}" ) - async def get_motion_profile_values( + async def request_motion_profile_values( self, profile: int ) -> tuple[int, float, float, float, float, float, float, float, bool]: """ @@ -1678,7 +1678,7 @@ async def change_config2(self, grip_mode: int = 0) -> None: """ await self._driver.send_command(f"ChangeConfig2 {grip_mode}") - async def _get_grasp_data(self) -> tuple[float, float, float]: + async def _request_grasp_data(self) -> tuple[float, float, float]: """Get the data to be used for the next force-controlled PickPlate command grip operation. Returns: @@ -1706,7 +1706,7 @@ async def _set_grasp_data( """ await self._driver.send_command(f"GraspData {plate_width} {finger_speed_percent} {grasp_force}") - async def _get_grip_close_pos(self) -> float: + async def _request_grip_close_pos(self) -> float: """Get the gripper close position for the servoed gripper. Returns: @@ -1725,7 +1725,7 @@ async def _set_grip_close_pos(self, close_position: float) -> None: """ await self._driver.send_command(f"GripClosePos {close_position}") - async def _get_grip_open_pos(self) -> float: + async def _request_grip_open_pos(self) -> float: """Get the gripper open position for the servoed gripper. Returns: diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py index 2b3f93dccd7..6b8031df3e3 100644 --- a/pylabrobot/byonoy/absorbance_96.py +++ b/pylabrobot/byonoy/absorbance_96.py @@ -32,9 +32,9 @@ def __init__(self) -> None: async def setup(self, **backend_kwargs) -> None: await super().setup(**backend_kwargs) await self.initialize_measurements() - self.available_wavelengths = await self.get_available_absorbance_wavelengths() + self.available_wavelengths = await self.request_available_absorbance_wavelengths() - async def get_available_absorbance_wavelengths(self) -> List[float]: + async def request_available_absorbance_wavelengths(self) -> List[float]: response = await self.send_command( report_id=0x0330, payload=b"\x00" * 60, diff --git a/pylabrobot/capabilities/humidity_controlling/backend.py b/pylabrobot/capabilities/humidity_controlling/backend.py index ee60deeadf2..e33f18b54ea 100644 --- a/pylabrobot/capabilities/humidity_controlling/backend.py +++ b/pylabrobot/capabilities/humidity_controlling/backend.py @@ -16,5 +16,5 @@ async def set_humidity(self, humidity: float): """Set the target humidity as a fraction 0.0-1.0.""" @abstractmethod - async def get_current_humidity(self) -> float: + async def request_current_humidity(self) -> float: """Get the current humidity as a fraction 0.0-1.0.""" diff --git a/pylabrobot/capabilities/humidity_controlling/humidity_controller.py b/pylabrobot/capabilities/humidity_controlling/humidity_controller.py index 066a4e7de94..622bed96932 100644 --- a/pylabrobot/capabilities/humidity_controlling/humidity_controller.py +++ b/pylabrobot/capabilities/humidity_controlling/humidity_controller.py @@ -20,9 +20,9 @@ async def set_humidity(self, humidity: float): raise ValueError("Backend does not support humidity control (read-only).") await self.backend.set_humidity(humidity) - async def get_humidity(self) -> float: + async def request_humidity(self) -> float: """Get the current humidity as a fraction 0.0-1.0.""" - return await self.backend.get_current_humidity() + return await self.backend.request_current_humidity() async def _on_stop(self): await super()._on_stop() diff --git a/pylabrobot/capabilities/temperature_controlling/backend.py b/pylabrobot/capabilities/temperature_controlling/backend.py index 2470fd0fc56..d204b656462 100644 --- a/pylabrobot/capabilities/temperature_controlling/backend.py +++ b/pylabrobot/capabilities/temperature_controlling/backend.py @@ -16,7 +16,7 @@ async def set_temperature(self, temperature: float): """Set the temperature of the temperature controller in Celsius.""" @abstractmethod - async def get_current_temperature(self) -> float: + async def request_current_temperature(self) -> float: """Get the current temperature of the temperature controller in Celsius""" @abstractmethod diff --git a/pylabrobot/capabilities/temperature_controlling/temperature_controller.py b/pylabrobot/capabilities/temperature_controlling/temperature_controller.py index afd6f547c5b..250573ac793 100644 --- a/pylabrobot/capabilities/temperature_controlling/temperature_controller.py +++ b/pylabrobot/capabilities/temperature_controlling/temperature_controller.py @@ -25,7 +25,7 @@ async def set_temperature(self, temperature: float, passive: bool = False): This can be used for backends that do not support active cooling or to explicitly disable active cooling when it is available. """ - current = await self.backend.get_current_temperature() + current = await self.backend.request_current_temperature() self.target_temperature = temperature @@ -43,9 +43,9 @@ async def set_temperature(self, temperature: float, passive: bool = False): return await self.backend.set_temperature(temperature) - async def get_temperature(self) -> float: + async def request_temperature(self) -> float: """Get the current temperature of the temperature controller in Celsius.""" - return await self.backend.get_current_temperature() + return await self.backend.request_current_temperature() async def wait_for_temperature(self, timeout: float = 300.0, tolerance: float = 0.5) -> None: """Wait for the temperature to reach the target temperature. The target temperature must be @@ -59,7 +59,7 @@ async def wait_for_temperature(self, timeout: float = 300.0, tolerance: float = raise RuntimeError("Target temperature is not set.") start = time.time() while time.time() - start < timeout: - temperature = await self.get_temperature() + temperature = await self.request_temperature() if abs(temperature - self.target_temperature) < tolerance: return await asyncio.sleep(1.0) diff --git a/pylabrobot/hamilton/heater_shaker/backend.py b/pylabrobot/hamilton/heater_shaker/backend.py index c1f2714fd2f..0ce32a16157 100644 --- a/pylabrobot/hamilton/heater_shaker/backend.py +++ b/pylabrobot/hamilton/heater_shaker/backend.py @@ -73,7 +73,7 @@ async def start_shaking( now = time.time() while True: await self._start_shaking(direction=direction, speed=int_speed, acceleration=acceleration) - if await self.get_is_shaking(): + if await self.request_is_shaking(): break if timeout is not None and time.time() - now > timeout: raise TimeoutError("Failed to start shaking within timeout") @@ -82,7 +82,7 @@ async def stop_shaking(self): await self._stop_shaking() await self._wait_for_stop() - async def get_is_shaking(self) -> bool: + async def request_is_shaking(self) -> bool: response = await self._driver.send_command("RD") return response.endswith("1") @@ -130,19 +130,19 @@ async def set_temperature(self, temperature: float): temp_str = f"{round(10 * temperature):04d}" return await self._driver.send_command("TA", ta=temp_str) - async def _get_current_temperature(self) -> Dict[str, float]: + async def _request_current_temperature(self) -> Dict[str, float]: response = await self._driver.send_command("RT") response = response.split("rt")[1] middle_temp = float(str(response).split(" ")[0].strip("+")) / 10 edge_temp = float(str(response).split(" ")[1].strip("+")) / 10 return {"middle": middle_temp, "edge": edge_temp} - async def get_current_temperature(self) -> float: - response = await self._get_current_temperature() + async def request_current_temperature(self) -> float: + response = await self._request_current_temperature() return response["middle"] - async def get_edge_temperature(self) -> float: - response = await self._get_current_temperature() + async def request_edge_temperature(self) -> float: + response = await self._request_current_temperature() return response["edge"] async def deactivate(self): diff --git a/pylabrobot/hamilton/liquid_handlers/base.py b/pylabrobot/hamilton/liquid_handlers/base.py index 4f5b361725f..6cb24274667 100644 --- a/pylabrobot/hamilton/liquid_handlers/base.py +++ b/pylabrobot/hamilton/liquid_handlers/base.py @@ -453,7 +453,7 @@ async def define_tip_needle( ): """Tip/needle definition in firmware.""" - async def get_or_assign_tip_type_index(self, tip: HamiltonTip) -> int: + async def request_or_assign_tip_type_index(self, tip: HamiltonTip) -> int: """Get a tip type table index for the tip. If the tip has previously been defined, used that index. Otherwise, define a new tip type. diff --git a/pylabrobot/hamilton/liquid_handlers/star/core.py b/pylabrobot/hamilton/liquid_handlers/star/core.py index 035c07c9741..2a51a4ef049 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/core.py +++ b/pylabrobot/hamilton/liquid_handlers/star/core.py @@ -20,8 +20,8 @@ def __init__(self, driver: STARDriver): # -- lifecycle -------------------------------------------------------------- - async def get_gripper_location(self, backend_params=None) -> GripperLocation: - raise NotImplementedError("CoreGripper does not support get_gripper_location") + async def request_gripper_location(self, backend_params=None) -> GripperLocation: + raise NotImplementedError("CoreGripper does not support request_gripper_location") # -- ArmBackend interface --------------------------------------------------- diff --git a/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py b/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py index 6c62fd868be..5448059b0e7 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py @@ -88,7 +88,7 @@ async def pick_up_tips96( if not isinstance(prototypical_tip, HamiltonTip): raise TypeError("Tip type must be HamiltonTip.") - ttti = await self._driver.get_or_assign_tip_type_index(prototypical_tip) + ttti = await self._driver.request_or_assign_tip_type_index(prototypical_tip) tip_length = prototypical_tip.total_tip_length fitting_depth = prototypical_tip.fitting_depth diff --git a/pylabrobot/hamilton/liquid_handlers/star/iswap.py b/pylabrobot/hamilton/liquid_handlers/star/iswap.py index 851eb5a7de3..7eb5a140ec8 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/iswap.py +++ b/pylabrobot/hamilton/liquid_handlers/star/iswap.py @@ -38,8 +38,8 @@ def version(self) -> str: def parked(self) -> bool: return self._parked is True - async def get_gripper_location(self, backend_params=None) -> GripperLocation: - raise NotImplementedError("iSWAP does not support get_gripper_location") + async def request_gripper_location(self, backend_params=None) -> GripperLocation: + raise NotImplementedError("iSWAP does not support request_gripper_location") async def _on_setup(self) -> None: if self._version is None: diff --git a/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py index cd3c0282b97..80f959429f3 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py @@ -260,7 +260,7 @@ async def pick_up_tips( raise ValueError("Cannot mix tips with different tip types.") ham_tip = tips.pop() assert isinstance(ham_tip, HamiltonTip) - ttti = await self._driver.get_or_assign_tip_type_index(ham_tip) + ttti = await self._driver.request_or_assign_tip_type_index(ham_tip) # Z computations (absolute coordinates). max_z = max( diff --git a/pylabrobot/inheco/cpac.py b/pylabrobot/inheco/cpac.py index 37c524d2909..1d74b8c3c54 100644 --- a/pylabrobot/inheco/cpac.py +++ b/pylabrobot/inheco/cpac.py @@ -42,7 +42,7 @@ async def set_temperature(self, temperature: float): await self.set_target_temperature(temperature) await self.start_temperature_control() - async def get_current_temperature(self) -> float: + async def request_current_temperature(self) -> float: response = await self.interface.send_command(f"{self.index}RAT0") return float(response) / 10 @@ -65,7 +65,7 @@ async def stop_temperature_control(self): # --- firmware misc - async def get_device_info(self, info_type: int): + async def request_device_info(self, info_type: int): """Get device information - 0 Bootstrap Version diff --git a/pylabrobot/inheco/scila/scila_backend.py b/pylabrobot/inheco/scila/scila_backend.py index 6caf1f97a14..95b62953870 100644 --- a/pylabrobot/inheco/scila/scila_backend.py +++ b/pylabrobot/inheco/scila/scila_backend.py @@ -143,7 +143,7 @@ async def set_temperature(self, temperature: float) -> None: "SetTemperature", targetTemperature=temperature, temperatureControl=True ) - async def get_current_temperature(self) -> float: + async def request_current_temperature(self) -> float: return (await self.request_temperature_information())["CurrentTemperature"] # type: ignore async def deactivate(self) -> None: diff --git a/pylabrobot/inheco/scila/scila_backend_tests.py b/pylabrobot/inheco/scila/scila_backend_tests.py index 3c61fd78224..9aba3e51722 100644 --- a/pylabrobot/inheco/scila/scila_backend_tests.py +++ b/pylabrobot/inheco/scila/scila_backend_tests.py @@ -202,7 +202,7 @@ async def test_request_temperature_information(self): ) self.mock_sila_interface.send_command.assert_called_with("GetTemperature") - async def test_get_current_temperature(self): + async def test_request_current_temperature(self): self.mock_sila_interface.send_command.return_value = ET.fromstring( "" " 25.0" @@ -210,7 +210,7 @@ async def test_get_current_temperature(self): " true" "" ) - temp = await self.backend.get_current_temperature() + temp = await self.backend.request_current_temperature() self.assertEqual(temp, 25.0) async def test_request_target_temperature(self): diff --git a/pylabrobot/io/ftdi.py b/pylabrobot/io/ftdi.py index e33b48e1b54..ee967cc8ce2 100644 --- a/pylabrobot/io/ftdi.py +++ b/pylabrobot/io/ftdi.py @@ -293,7 +293,7 @@ async def poll_modem_status(self) -> int: ) return stat.value - async def get_serial(self) -> str: + async def request_serial(self) -> str: return self.device_id async def stop(self): diff --git a/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py index 1ad3081173f..cbbf57ff79a 100644 --- a/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py +++ b/pylabrobot/legacy/arms/precise_flex/precise_flex_backend.py @@ -134,7 +134,7 @@ async def set_speed(self, speed_percent: float): await self._new_backend._set_speed(speed_percent) async def get_speed(self) -> float: - return await self._new_backend._get_speed() + return await self._new_backend._request_speed() async def open_gripper(self, gripper_width: float): await self._new_backend.open_gripper(gripper_width) @@ -234,10 +234,10 @@ async def move_to(self, position: Union[PreciseFlexCartesianCoords, Dict[int, fl await self._new_backend.move_to_location(converted.location, converted.rotation.z) async def get_joint_position(self) -> Dict[int, float]: - return await self._new_backend.get_joint_position() + return await self._new_backend.request_joint_position() async def get_cartesian_position(self) -> PreciseFlexCartesianCoords: - result = await self._new_backend.get_gripper_location() + result = await self._new_backend.request_gripper_location() return _from_new_coords(result) async def send_command(self, command: str) -> str: @@ -264,25 +264,25 @@ async def set_base( await self._new_backend.set_base(x_offset, y_offset, z_offset, z_rotation) async def get_base(self) -> tuple[float, float, float, float]: - return await self._new_backend.get_base() + return await self._new_backend.request_base() async def exit(self) -> None: await self._new_driver.exit() async def get_power_state(self) -> int: - return await self._new_driver.get_power_state() + return await self._new_driver.request_power_state() async def set_power(self, enable: bool, timeout: int = 0) -> None: await self._new_driver.set_power(enable, timeout) async def get_mode(self): - return await self._new_driver.get_mode() + return await self._new_driver.request_mode() async def set_response_mode(self, mode) -> None: await self._new_driver.set_response_mode(mode) async def get_monitor_speed(self) -> int: - return await self._new_backend.get_monitor_speed() + return await self._new_backend.request_monitor_speed() async def set_monitor_speed(self, speed_percent: int) -> None: await self._new_backend.set_monitor_speed(speed_percent) @@ -291,7 +291,7 @@ async def nop(self) -> None: await self._new_backend.nop() async def get_payload(self) -> int: - return await self._new_backend.get_payload() + return await self._new_backend.request_payload() async def set_payload(self, payload_percent: int) -> None: await self._new_backend.set_payload(payload_percent) @@ -300,34 +300,34 @@ async def set_parameter(self, data_id, value, unit_number=None, sub_unit=None, a await self._new_backend.set_parameter(data_id, value, unit_number, sub_unit, array_index) async def get_parameter(self, data_id, unit_number=None, sub_unit=None, array_index=None): - return await self._new_backend.get_parameter(data_id, unit_number, sub_unit, array_index) + return await self._new_backend.request_parameter(data_id, unit_number, sub_unit, array_index) async def reset(self, robot_number: int) -> None: await self._new_backend.reset(robot_number) async def get_selected_robot(self) -> int: - return await self._new_backend.get_selected_robot() + return await self._new_backend.request_selected_robot() async def select_robot(self, robot_number: int) -> None: await self._new_backend.select_robot(robot_number) async def get_signal(self, signal_number: int) -> int: - return await self._new_backend.get_signal(signal_number) + return await self._new_backend.request_signal(signal_number) async def set_signal(self, signal_number: int, value: int) -> None: await self._new_backend.set_signal(signal_number, value) async def get_system_state(self) -> int: - return await self._new_backend.get_system_state() + return await self._new_backend.request_system_state() async def get_tool_transformation_values(self): - return await self._new_backend.get_tool_transformation_values() + return await self._new_backend.request_tool_transformation_values() async def set_tool_transformation_values(self, x, y, z, yaw, pitch, roll): await self._new_backend.set_tool_transformation_values(x, y, z, yaw, pitch, roll) async def get_version(self) -> str: - return await self._new_backend.get_version() + return await self._new_backend.request_version() async def get_location_angles(self, location_index): data = await self.send_command(f"locAngles {location_index}") @@ -402,49 +402,49 @@ async def here_c(self, location_index): await self._new_backend.here_c(location_index) async def get_profile_speed(self, profile_index): - return await self._new_backend.get_profile_speed(profile_index) + return await self._new_backend.request_profile_speed(profile_index) async def set_profile_speed(self, profile_index, speed_percent): await self._new_backend.set_profile_speed(profile_index, speed_percent) async def get_profile_speed2(self, profile_index): - return await self._new_backend.get_profile_speed2(profile_index) + return await self._new_backend.request_profile_speed2(profile_index) async def set_profile_speed2(self, profile_index, speed2_percent): await self._new_backend.set_profile_speed2(profile_index, speed2_percent) async def get_profile_accel(self, profile_index): - return await self._new_backend.get_profile_accel(profile_index) + return await self._new_backend.request_profile_accel(profile_index) async def set_profile_accel(self, profile_index, accel_percent): await self._new_backend.set_profile_accel(profile_index, accel_percent) async def get_profile_accel_ramp(self, profile_index): - return await self._new_backend.get_profile_accel_ramp(profile_index) + return await self._new_backend.request_profile_accel_ramp(profile_index) async def set_profile_accel_ramp(self, profile_index, accel_ramp_seconds): await self._new_backend.set_profile_accel_ramp(profile_index, accel_ramp_seconds) async def get_profile_decel(self, profile_index): - return await self._new_backend.get_profile_decel(profile_index) + return await self._new_backend.request_profile_decel(profile_index) async def set_profile_decel(self, profile_index, decel_percent): await self._new_backend.set_profile_decel(profile_index, decel_percent) async def get_profile_decel_ramp(self, profile_index): - return await self._new_backend.get_profile_decel_ramp(profile_index) + return await self._new_backend.request_profile_decel_ramp(profile_index) async def set_profile_decel_ramp(self, profile_index, decel_ramp_seconds): await self._new_backend.set_profile_decel_ramp(profile_index, decel_ramp_seconds) async def get_profile_in_range(self, profile_index): - return await self._new_backend.get_profile_in_range(profile_index) + return await self._new_backend.request_profile_in_range(profile_index) async def set_profile_in_range(self, profile_index, in_range_value): await self._new_backend.set_profile_in_range(profile_index, in_range_value) async def get_profile_straight(self, profile_index): - return await self._new_backend.get_profile_straight(profile_index) + return await self._new_backend.request_profile_straight(profile_index) async def set_profile_straight(self, profile_index, straight_mode): await self._new_backend.set_profile_straight(profile_index, straight_mode) @@ -474,7 +474,7 @@ async def set_motion_profile_values( ) async def get_motion_profile_values(self, profile): - return await self._new_backend.get_motion_profile_values(profile) + return await self._new_backend.request_motion_profile_values(profile) async def move_to_stored_location(self, location_index, profile_index): await self._new_backend._move_to_stored_location(location_index, profile_index) @@ -522,19 +522,19 @@ async def change_config2(self, grip_mode=0): await self._new_backend.change_config2(grip_mode) async def get_grasp_data(self): - return await self._new_backend._get_grasp_data() + return await self._new_backend._request_grasp_data() async def set_grasp_data(self, plate_width, finger_speed_percent, grasp_force): await self._new_backend._set_grasp_data(plate_width, finger_speed_percent, grasp_force) async def _get_grip_close_pos(self): - return await self._new_backend._get_grip_close_pos() + return await self._new_backend._request_grip_close_pos() async def _set_grip_close_pos(self, close_position): await self._new_backend._set_grip_close_pos(close_position) async def _get_grip_open_pos(self): - return await self._new_backend._get_grip_open_pos() + return await self._new_backend._request_grip_open_pos() async def _set_grip_open_pos(self, open_position): await self._new_backend._set_grip_open_pos(open_position) diff --git a/pylabrobot/legacy/centrifuge/vspin_backend.py b/pylabrobot/legacy/centrifuge/vspin_backend.py index fd82e1b9796..3a29e9d236f 100644 --- a/pylabrobot/legacy/centrifuge/vspin_backend.py +++ b/pylabrobot/legacy/centrifuge/vspin_backend.py @@ -45,7 +45,7 @@ async def send_command(self, command: bytes) -> bytes: return await self._driver.send_command(command) async def get_status(self) -> bytes: - return await self._driver.get_status() + return await self._driver.request_status() async def park(self): await self._driver.park() @@ -112,25 +112,25 @@ async def set_bucket_1_position_to_current(self) -> None: await self._centrifuge.set_bucket_1_position_to_current() async def get_bucket_1_position(self) -> int: - return await self._centrifuge.get_bucket_1_position() + return await self._centrifuge.request_bucket_1_position() async def get_position(self) -> int: - return await self._driver.get_position() + return await self._driver.request_position() async def get_tachometer(self) -> int: - return await self._driver.get_tachometer() + return await self._driver.request_tachometer() async def get_home_position(self) -> int: - return await self._driver.get_home_position() + return await self._driver.request_home_position() async def get_bucket_locked(self) -> bool: - return await self._driver.get_bucket_locked() + return await self._driver.request_bucket_locked() async def get_door_open(self) -> bool: - return await self._driver.get_door_open() + return await self._driver.request_door_open() async def get_door_locked(self) -> bool: - return await self._driver.get_door_locked() + return await self._driver.request_door_locked() async def open_door(self): await self._centrifuge.open_door() diff --git a/pylabrobot/legacy/heating_shaking/bioshake_backend.py b/pylabrobot/legacy/heating_shaking/bioshake_backend.py index 08c2f14d698..e167741af86 100644 --- a/pylabrobot/legacy/heating_shaking/bioshake_backend.py +++ b/pylabrobot/legacy/heating_shaking/bioshake_backend.py @@ -58,7 +58,7 @@ async def set_temperature(self, temperature: float): await self._temp.set_temperature(temperature) async def get_current_temperature(self) -> float: - return await self._temp.get_current_temperature() + return await self._temp.request_current_temperature() async def deactivate(self): await self._temp.deactivate() diff --git a/pylabrobot/legacy/heating_shaking/hamilton_backend.py b/pylabrobot/legacy/heating_shaking/hamilton_backend.py index 5c96ddf91c4..a737d83f656 100644 --- a/pylabrobot/legacy/heating_shaking/hamilton_backend.py +++ b/pylabrobot/legacy/heating_shaking/hamilton_backend.py @@ -71,7 +71,7 @@ async def stop_shaking(self): await self._shaker.stop_shaking() async def get_is_shaking(self) -> bool: - return await self._shaker.get_is_shaking() + return await self._shaker.request_is_shaking() async def lock_plate(self): await self._shaker.lock_plate() @@ -83,13 +83,13 @@ async def set_temperature(self, temperature: float): await self._temp.set_temperature(temperature=temperature) async def get_current_temperature(self) -> float: - return await self._temp.get_current_temperature() + return await self._temp.request_current_temperature() async def _get_current_temperature(self) -> Dict[str, float]: - return await self._temp._get_current_temperature() + return await self._temp._request_current_temperature() async def get_edge_temperature(self) -> float: - return await self._temp.get_edge_temperature() + return await self._temp.request_edge_temperature() async def deactivate(self): await self._temp.deactivate() diff --git a/pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py b/pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py index 96355044022..a578826029d 100644 --- a/pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py +++ b/pylabrobot/legacy/heating_shaking/inheco/thermoshake_backend.py @@ -39,7 +39,7 @@ async def set_temperature(self, temperature: float): await self._new.set_temperature(temperature) async def get_current_temperature(self) -> float: - return await self._new.get_current_temperature() + return await self._new.request_current_temperature() async def deactivate(self): await self._new.deactivate() @@ -54,7 +54,7 @@ async def stop_temperature_control(self): return await self._new.stop_temperature_control() async def get_device_info(self, info_type: int): - return await self._new.get_device_info(info_type) + return await self._new.request_device_info(info_type) async def start_shaking(self, speed: float, shape: int = 0): await self._new.start_shaking(speed=speed, shape=shape) diff --git a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py index 72231ea4101..f84cacabec6 100644 --- a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py +++ b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py @@ -80,7 +80,7 @@ async def stop(self) -> None: await self._driver.stop() async def get_configuration(self) -> dict: - return await self._driver.get_configuration() + return await self._driver.request_configuration() async def open_door(self) -> None: await self._driver.open_door() @@ -95,10 +95,10 @@ async def exit_objective_maintenance(self) -> None: await self._microscopy.exit_objective_maintenance() async def get_available_objectives(self, position: int) -> List[dict]: - return await self._microscopy.get_available_objectives(position) + return await self._microscopy.request_available_objectives(position) async def get_available_filter_cubes(self) -> List[dict]: - return await self._microscopy.get_available_filter_cubes() + return await self._microscopy.request_available_filter_cubes() async def change_objective(self, position: int, objective_id: str) -> None: await self._microscopy.change_objective(position, objective_id) diff --git a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py index 8bb74fcb40b..a3c6732d329 100644 --- a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py +++ b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend_tests.py @@ -278,7 +278,7 @@ async def test_setup_configures_objectives_and_filter_cubes(self): f"/{_FC_SVC}/Get_InstalledFilterCubes", _sila_string_response(json.dumps({"filterCubesData": [{"Id": "DAPI"}]})), ) - # get_available_objectives / get_available_filter_cubes for validation + # request_available_objectives / request_available_filter_cubes for validation channel.set_response( f"/{_OBJ_SVC}/GetAvailableObjectivesForPosition", _sila_string_response(json.dumps({"objectives": [{"Id": "PL FLUOTAR 4x/0.13"}]})), @@ -394,7 +394,7 @@ async def test_exit_maintenance(self): # --------------------------------------------------------------------------- -# Tests: get_configuration command + response decoding +# Tests: request_configuration command + response decoding # --------------------------------------------------------------------------- @@ -412,7 +412,7 @@ async def test_decodes_instrument_configuration(self): _sila_string_response(json.dumps(config)), ) - result = await backend.get_configuration() + result = await backend.request_configuration() self.assertEqual(len(channel.calls), 1) self.assertEqual(channel.calls[0].path, f"/{_INST_SVC}/Get_InstrumentConfiguration") diff --git a/pylabrobot/legacy/peeling/xpeel_backend.py b/pylabrobot/legacy/peeling/xpeel_backend.py index 38841cb26e8..b9ec99d98e2 100644 --- a/pylabrobot/legacy/peeling/xpeel_backend.py +++ b/pylabrobot/legacy/peeling/xpeel_backend.py @@ -33,22 +33,22 @@ async def reset(self): return await self._driver.reset() async def get_status(self): - return await self._driver.get_status() + return await self._driver.request_status() async def get_version(self): - return await self._driver.get_version() + return await self._driver.request_version() async def seal_check(self): return await self._driver.seal_check() async def get_tape_remaining(self): - return await self._driver.get_tape_remaining() + return await self._driver.request_tape_remaining() async def enable_plate_check(self, enabled=True): return await self._driver.enable_plate_check(enabled=enabled) async def get_seal_sensor_status(self): - return await self._driver.get_seal_sensor_status() + return await self._driver.request_seal_sensor_status() async def set_seal_threshold_upper(self, value: int): return await self._driver.set_seal_threshold_upper(value=value) diff --git a/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py b/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py index 343359a8261..5a874b8ebe7 100644 --- a/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py +++ b/pylabrobot/legacy/plate_reading/agilent/biotek_backend.py @@ -114,10 +114,10 @@ async def _read_until(self, terminator, timeout=None): return await self._new._read_until(terminator, timeout) async def get_serial_number(self): - return await self._new.get_serial_number() + return await self._new.request_serial_number() async def get_firmware_version(self): - return await self._new.get_firmware_version() + return await self._new.request_firmware_version() async def open(self, slow=False): return await self._new.open(slow=slow) @@ -129,7 +129,7 @@ async def home(self): return await self._new.home() async def get_current_temperature(self): - return await self._new.get_current_temperature() + return await self._new.request_current_temperature() async def set_temperature(self, temperature): return await self._new.set_temperature(temperature) diff --git a/pylabrobot/legacy/sealing/a4s_backend.py b/pylabrobot/legacy/sealing/a4s_backend.py index b7b61b374a1..628b10c4fb2 100644 --- a/pylabrobot/legacy/sealing/a4s_backend.py +++ b/pylabrobot/legacy/sealing/a4s_backend.py @@ -38,7 +38,7 @@ async def set_temperature(self, temperature: float): await self._temperature.set_temperature(temperature=temperature) async def get_temperature(self) -> float: - return await self._temperature.get_current_temperature() + return await self._temperature.request_current_temperature() async def set_heater(self, on: bool): await self._driver.set_heater(on=on) @@ -50,7 +50,7 @@ async def set_time(self, seconds: float): await self._driver.set_time(seconds=seconds) async def get_remaining_time(self) -> int: - return await self._driver.get_remaining_time() + return await self._driver.request_remaining_time() async def get_status(self): - return await self._driver.get_status() + return await self._driver.request_status() diff --git a/pylabrobot/legacy/storage/cytomat/cytomat.py b/pylabrobot/legacy/storage/cytomat/cytomat.py index 2eaf0f27581..ffd6fe6623a 100644 --- a/pylabrobot/legacy/storage/cytomat/cytomat.py +++ b/pylabrobot/legacy/storage/cytomat/cytomat.py @@ -49,7 +49,7 @@ async def set_temperature(self, *args, **kwargs): return await self._new.set_temperature(*args, **kwargs) async def get_temperature(self) -> float: - return await self._new.get_current_temperature() + return await self._new.request_current_temperature() async def start_shaking(self, frequency: float, shakers: Optional[List[int]] = None): return await self._new.start_shaking(speed=frequency, shakers=shakers) @@ -69,13 +69,13 @@ async def send_action(self, command_type, command, params, timeout=60): return await self._new.send_action(command_type, command, params, timeout=timeout) async def get_overview_register(self): - return await self._new.get_overview_register() + return await self._new.request_overview_register() async def get_warning_register(self): - return await self._new.get_warning_register() + return await self._new.request_warning_register() async def get_error_register(self): - return await self._new.get_error_register() + return await self._new.request_error_register() async def reset_error_register(self): return await self._new.reset_error_register() @@ -90,13 +90,13 @@ async def shovel_out(self): return await self._new.shovel_out() async def get_action_register(self): - return await self._new.get_action_register() + return await self._new.request_action_register() async def get_swap_register(self): - return await self._new.get_swap_register() + return await self._new.request_swap_register() async def get_sensor_register(self): - return await self._new.get_sensor_register() + return await self._new.request_sensor_register() async def action_transfer_to_storage(self, site): return await self._new.action_transfer_to_storage(site) @@ -144,16 +144,16 @@ async def set_shaking_frequency(self, frequency, shakers=None): return await self._new.set_shaking_frequency(frequency, shakers) async def get_incubation_query(self, query): - return await self._new.get_incubation_query(query) + return await self._new.request_incubation_query(query) async def get_co2(self): - return await self._new.get_co2() + return await self._new.request_co2() async def get_humidity(self): - return await self._new.get_humidity() + return await self._new.request_humidity() async def get_o2(self): - return await self._new.get_o2() + return await self._new.request_o2() def serialize(self) -> dict: return self._new.serialize() diff --git a/pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py b/pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py index 57a67bae1bc..66bb8263c3a 100644 --- a/pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py +++ b/pylabrobot/legacy/storage/cytomat/heraeus_cytomat_backend.py @@ -45,7 +45,7 @@ async def set_temperature(self, temperature: float): return await self._new.set_temperature(temperature) async def get_temperature(self) -> float: - return await self._new.get_current_temperature() + return await self._new.request_current_temperature() async def start_shaking(self, frequency: float = 1.0): await self._new.start_shaking(speed=frequency) diff --git a/pylabrobot/legacy/storage/inheco/scila/scila_backend.py b/pylabrobot/legacy/storage/inheco/scila/scila_backend.py index 127ebd147d7..8283f1db379 100644 --- a/pylabrobot/legacy/storage/inheco/scila/scila_backend.py +++ b/pylabrobot/legacy/storage/inheco/scila/scila_backend.py @@ -37,7 +37,7 @@ async def request_temperature_information(self) -> dict[str, Any]: return await self._temp.request_temperature_information() async def measure_temperature(self) -> float: - return await self._temp.get_current_temperature() + return await self._temp.request_current_temperature() async def request_target_temperature(self) -> float: return await self._temp.request_target_temperature() diff --git a/pylabrobot/legacy/storage/liconic/liconic_backend.py b/pylabrobot/legacy/storage/liconic/liconic_backend.py index 78934a9f533..3473ae2ead6 100644 --- a/pylabrobot/legacy/storage/liconic/liconic_backend.py +++ b/pylabrobot/legacy/storage/liconic/liconic_backend.py @@ -194,7 +194,7 @@ async def set_temperature(self, temperature: float): await self._new.set_temperature(temperature) async def get_temperature(self) -> float: - return await self._new.get_current_temperature() + return await self._new.request_current_temperature() async def start_shaking(self, frequency): await self._new.start_shaking(speed=frequency) @@ -203,40 +203,40 @@ async def stop_shaking(self): await self._new.stop_shaking() async def get_shaker_speed(self) -> float: - return await self._new.get_shaker_speed() + return await self._new.request_shaker_speed() async def shaker_status(self) -> int: raise NotImplementedError("shaker_status command not yet implemented") async def get_target_temperature(self) -> float: - return await self._new.get_target_temperature() + return await self._new.request_target_temperature() async def set_humidity(self, humidity: float): await self._new.set_humidity(humidity) async def get_humidity(self) -> float: - return await self._new.get_current_humidity() + return await self._new.request_current_humidity() async def get_target_humidity(self) -> float: - return await self._new.get_target_humidity() + return await self._new.request_target_humidity() async def set_co2_level(self, co2_level: float): await self._new.set_co2_level(co2_level) async def get_co2_level(self) -> float: - return await self._new.get_co2_level() + return await self._new.request_co2_level() async def get_target_co2_level(self) -> float: - return await self._new.get_target_co2_level() + return await self._new.request_target_co2_level() async def set_n2_level(self, n2_level: float): await self._new.set_n2_level(n2_level) async def get_n2_level(self) -> float: - return await self._new.get_n2_level() + return await self._new.request_n2_level() async def get_target_n2_level(self) -> float: - return await self._new.get_target_n2_level() + return await self._new.request_target_n2_level() async def turn_swap_station(self, home: bool): await self._new.turn_swap_station(home) diff --git a/pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py b/pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py index fd9ab2160eb..0a69311d426 100644 --- a/pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py +++ b/pylabrobot/legacy/temperature_controlling/inheco/temperature_controller.py @@ -33,7 +33,7 @@ async def set_temperature(self, temperature: float): await self._new.set_temperature(temperature) async def get_current_temperature(self) -> float: - return await self._new.get_current_temperature() + return await self._new.request_current_temperature() async def deactivate(self): await self._new.deactivate() @@ -48,4 +48,4 @@ async def stop_temperature_control(self): return await self._new.stop_temperature_control() async def get_device_info(self, info_type: int): - return await self._new.get_device_info(info_type) + return await self._new.request_device_info(info_type) diff --git a/pylabrobot/legacy/temperature_controlling/opentrons_backend.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py index 5a39c09bcd9..1983e2f4471 100644 --- a/pylabrobot/legacy/temperature_controlling/opentrons_backend.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py @@ -41,4 +41,4 @@ async def deactivate(self): await self._backend.deactivate() async def get_current_temperature(self) -> float: - return await self._backend.get_current_temperature() + return await self._backend.request_current_temperature() diff --git a/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py index 6cd0ae1e64a..d4170034fa4 100644 --- a/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py @@ -41,4 +41,4 @@ async def deactivate(self): await self._backend.deactivate() async def get_current_temperature(self) -> float: - return await self._backend.get_current_temperature() + return await self._backend.request_current_temperature() diff --git a/pylabrobot/legacy/temperature_controlling/temperature_controller.py b/pylabrobot/legacy/temperature_controlling/temperature_controller.py index 9fa9851bf6e..f09000b0cbb 100644 --- a/pylabrobot/legacy/temperature_controlling/temperature_controller.py +++ b/pylabrobot/legacy/temperature_controlling/temperature_controller.py @@ -27,7 +27,7 @@ def supports_active_cooling(self) -> bool: async def set_temperature(self, temperature: float): await self._legacy.set_temperature(temperature) - async def get_current_temperature(self) -> float: + async def request_current_temperature(self) -> float: return await self._legacy.get_current_temperature() async def deactivate(self): diff --git a/pylabrobot/liconic/backend.py b/pylabrobot/liconic/backend.py index a0242d7f51b..0f04b7ec5b9 100644 --- a/pylabrobot/liconic/backend.py +++ b/pylabrobot/liconic/backend.py @@ -188,7 +188,7 @@ async def set_temperature(self, temperature: float): await self._send_command(f"WR DM890 {temp_str}") await self._wait_ready() - async def get_current_temperature(self) -> float: + async def request_current_temperature(self) -> float: if not self.model.has_temperature_control: raise NotImplementedError("Climate control is not supported on this model") resp = await self._send_command("RD DM982") @@ -213,7 +213,7 @@ async def set_humidity(self, humidity: float): await self._send_command(f"WR DM893 {str(humidity_val).zfill(5)}") await self._wait_ready() - async def get_current_humidity(self) -> float: + async def request_current_humidity(self) -> float: if not self.model.has_temperature_control: raise NotImplementedError("Climate control is not supported on this model") resp = await self._send_command("RD DM983") @@ -394,7 +394,7 @@ async def scan_barcode( logger.info("Scanned barcode: %s", barcode.data) return barcode - async def get_target_temperature(self) -> float: + async def request_target_temperature(self) -> float: if not self.model.has_temperature_control: raise NotImplementedError("Climate control is not supported on this model") resp = await self._send_command("RD DM890") @@ -403,7 +403,7 @@ async def get_target_temperature(self) -> float: except ValueError: raise RuntimeError(f"Invalid set temperature value received from incubator: {resp!r}") - async def get_target_humidity(self) -> float: + async def request_target_humidity(self) -> float: if not self.model.has_temperature_control: raise NotImplementedError("Climate control is not supported on this model") resp = await self._send_command("RD DM893") @@ -412,7 +412,7 @@ async def get_target_humidity(self) -> float: except ValueError: raise RuntimeError(f"Invalid set humidity value received from incubator: {resp!r}") - async def get_shaker_speed(self) -> float: + async def request_shaker_speed(self) -> float: speed_val = await self._send_command("RD DM39") speed = int(speed_val) / 10.0 await self._wait_ready() @@ -423,14 +423,14 @@ async def set_co2_level(self, co2_level: float): await self._send_command(f"WR DM894 {str(co2_val).zfill(5)}") await self._wait_ready() - async def get_co2_level(self) -> float: + async def request_co2_level(self) -> float: resp = await self._send_command("RD DM984") try: return int(resp) / 10000.0 except ValueError: raise RuntimeError(f"Invalid co2 value received from incubator: {resp!r}") - async def get_target_co2_level(self) -> float: + async def request_target_co2_level(self) -> float: resp = await self._send_command("RD DM894") try: return int(resp) / 10000.0 @@ -442,14 +442,14 @@ async def set_n2_level(self, n2_level: float): await self._send_command(f"WR DM895 {str(n2_val).zfill(5)}") await self._wait_ready() - async def get_n2_level(self) -> float: + async def request_n2_level(self) -> float: resp = await self._send_command("RD DM985") try: return int(resp) / 10000.0 except ValueError: raise RuntimeError(f"Invalid N2 value received from incubator: {resp!r}") - async def get_target_n2_level(self) -> float: + async def request_target_n2_level(self) -> float: resp = await self._send_command("RD DM895") try: return int(resp) / 10000.0 diff --git a/pylabrobot/molecular_devices/imageXpress/pico/backend.py b/pylabrobot/molecular_devices/imageXpress/pico/backend.py index 9079f0353f3..a5c4fc8dc4c 100644 --- a/pylabrobot/molecular_devices/imageXpress/pico/backend.py +++ b/pylabrobot/molecular_devices/imageXpress/pico/backend.py @@ -469,7 +469,7 @@ async def stop(self) -> None: self._channel = None logger.info("PicoDriver: stopped") - async def get_configuration(self) -> dict: + async def request_configuration(self) -> dict: """Query the full instrument configuration.""" raw = await self._call(_INST_SVC, "Get_InstrumentConfiguration", b"") data: dict = json.loads(decode_sila_string_response(raw)) @@ -527,7 +527,7 @@ def __init__( self._filter_cubes: Dict[int, ImagingMode] = filter_cubes or {} async def _on_setup(self): - installed_obj = await self._get_installed_objectives() + installed_obj = await self._request_installed_objectives() num_obj = len(installed_obj) for pos, obj in self._objectives.items(): if pos >= num_obj: @@ -536,7 +536,7 @@ async def _on_setup(self): ) await self.change_objective(pos, _OBJECTIVE_MAP[obj]) - installed_fc = await self._get_installed_filter_cubes() + installed_fc = await self._request_installed_filter_cubes() num_fc = len(installed_fc) for pos, mode in self._filter_cubes.items(): if pos >= num_fc: @@ -547,30 +547,30 @@ async def _on_setup(self): # -- objectives & filter cubes -- - async def _get_installed_objectives(self) -> List[dict]: + async def _request_installed_objectives(self) -> List[dict]: raw = await self._driver._call(_OBJ_SVC, "Get_InstalledObjectives", b"") data: dict = json.loads(decode_sila_string_response(raw)) return list(data.get("objectivesData", [])) - async def _get_installed_filter_cubes(self) -> List[dict]: + async def _request_installed_filter_cubes(self) -> List[dict]: raw = await self._driver._call(_FC_SVC, "Get_InstalledFilterCubes", b"") data: dict = json.loads(decode_sila_string_response(raw)) return list(data.get("filterCubesData", [])) - async def get_available_objectives(self, position: int) -> List[dict]: + async def request_available_objectives(self, position: int) -> List[dict]: params = json.dumps({"Index": position}) req = length_delimited(1, sila_string(params)) raw = await self._driver._call(_OBJ_SVC, "GetAvailableObjectivesForPosition", req, True) data: dict = json.loads(decode_sila_string_response(raw)) return list(data.get("objectives", data.get("Objectives", []))) - async def get_available_filter_cubes(self) -> List[dict]: + async def request_available_filter_cubes(self) -> List[dict]: raw = await self._driver._call(_FC_SVC, "Get_CompatibleFilterCubes", b"") data: dict = json.loads(decode_sila_string_response(raw)) return list(data.get("filterCubes", data.get("FilterCubes", []))) async def change_objective(self, position: int, objective_id: str) -> None: - available = await self.get_available_objectives(position) + available = await self.request_available_objectives(position) valid_ids = [obj.get("Id", obj.get("id")) for obj in available] if objective_id not in valid_ids: raise ValueError( @@ -582,7 +582,7 @@ async def change_objective(self, position: int, objective_id: str) -> None: await self._driver._call(_OBJ_SVC, "ChangeHardware", req, True) async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: - available = await self.get_available_filter_cubes() + available = await self.request_available_filter_cubes() valid_ids = [fc.get("Id", fc.get("id")) for fc in available] if filter_cube_id not in valid_ids: raise ValueError( diff --git a/pylabrobot/molecular_devices/spectramax/backend.py b/pylabrobot/molecular_devices/spectramax/backend.py index b03a982a649..434d91f90a1 100644 --- a/pylabrobot/molecular_devices/spectramax/backend.py +++ b/pylabrobot/molecular_devices/spectramax/backend.py @@ -359,7 +359,7 @@ async def close(self) -> None: """Close the plate tray.""" await self.send_command("!CLOSE") - async def get_status(self) -> List[str]: + async def request_status(self) -> List[str]: """Get the current device status.""" res = await self.send_command("!STATUS") if len(res) > 1: @@ -377,7 +377,7 @@ async def clear_error_log(self) -> None: """Clear the device error log.""" await self.send_command("!CLEAR ERROR") - async def get_firmware_version(self) -> List[str]: + async def request_firmware_version(self) -> List[str]: """Get the firmware version.""" res = await self.send_command("!OPTION") return res[1].split() @@ -396,7 +396,7 @@ async def wait_for_idle(self, timeout: int = 600): while True: if time.time() - start_time > timeout: raise TimeoutError("Timeout waiting for plate reader to become idle.") - status = await self.get_status() + status = await self.request_status() if status and status[1] == "IDLE": break await asyncio.sleep(1) @@ -812,7 +812,7 @@ def __init__(self, driver: MolecularDevicesDriver) -> None: def supports_active_cooling(self) -> bool: return False - async def get_temperature(self) -> Tuple[float, float]: + async def request_temperature(self) -> Tuple[float, float]: """Get (current_temp, set_point) from the device.""" res = await self._driver.send_command("!TEMP") if len(res) > 1: @@ -824,8 +824,8 @@ async def get_temperature(self) -> Tuple[float, float]: return (float(parts[1]), float(parts[0])) raise ValueError(f"Could not parse temperature from response: {res}") - async def get_current_temperature(self) -> float: - current, _ = await self.get_temperature() + async def request_current_temperature(self) -> float: + current, _ = await self.request_temperature() return current async def set_temperature(self, temperature: float) -> None: diff --git a/pylabrobot/opentrons/temperature_module/http_driver.py b/pylabrobot/opentrons/temperature_module/http_driver.py index 3bdcf0921c0..860eaea432b 100644 --- a/pylabrobot/opentrons/temperature_module/http_driver.py +++ b/pylabrobot/opentrons/temperature_module/http_driver.py @@ -57,7 +57,7 @@ async def set_temperature(self, temperature: float): async def deactivate(self): ot_api.modules.temperature_module_deactivate(module_id=self._driver.opentrons_id) - async def get_current_temperature(self) -> float: + async def request_current_temperature(self) -> float: modules = ot_api.modules.list_connected_modules() for module in modules: if module["id"] == self._driver.opentrons_id: diff --git a/pylabrobot/opentrons/temperature_module/usb_driver.py b/pylabrobot/opentrons/temperature_module/usb_driver.py index 93729470098..caaedb1ddc4 100644 --- a/pylabrobot/opentrons/temperature_module/usb_driver.py +++ b/pylabrobot/opentrons/temperature_module/usb_driver.py @@ -84,5 +84,5 @@ async def set_temperature(self, temperature: float): async def deactivate(self): await self._driver.send_and_check(b"M18\r\n") - async def get_current_temperature(self) -> float: + async def request_current_temperature(self) -> float: return await self._driver.query_temperature() diff --git a/pylabrobot/qinstruments/bioshake.py b/pylabrobot/qinstruments/bioshake.py index b249fad6cfd..e687f658ffb 100644 --- a/pylabrobot/qinstruments/bioshake.py +++ b/pylabrobot/qinstruments/bioshake.py @@ -233,7 +233,7 @@ async def set_temperature(self, temperature: float): await self._driver.send_command(cmd=f"setTempTarget{temperature_tenths}", delay=0.2) await self._driver.send_command(cmd="tempOn", delay=0.2) - async def get_current_temperature(self) -> float: + async def request_current_temperature(self) -> float: response = await self._driver.send_command(cmd="getTempActual", delay=0.2) return float(response) diff --git a/pylabrobot/thermo_fisher/cytomat/backend.py b/pylabrobot/thermo_fisher/cytomat/backend.py index 51a18cbc25b..22edf0aba7b 100644 --- a/pylabrobot/thermo_fisher/cytomat/backend.py +++ b/pylabrobot/thermo_fisher/cytomat/backend.py @@ -135,8 +135,8 @@ def supports_active_cooling(self) -> bool: async def set_temperature(self, temperature: float): raise NotImplementedError("Temperature is configured via the Cytomat device UI") - async def get_current_temperature(self) -> float: - return (await self.get_incubation_query("it")).actual_value + async def request_current_temperature(self) -> float: + return (await self.request_incubation_query("it")).actual_value async def deactivate(self): pass # no-op: temperature is device-managed @@ -150,8 +150,8 @@ def supports_humidity_control(self) -> bool: async def set_humidity(self, humidity: float): raise NotImplementedError("Humidity is configured via the Cytomat device UI") - async def get_current_humidity(self) -> float: - return (await self.get_incubation_query("ih")).actual_value + async def request_current_humidity(self) -> float: + return (await self.request_incubation_query("ih")).actual_value # -- ShakerBackend -- @@ -198,7 +198,7 @@ async def _send_command(command_str) -> str: if key == CytomatActionResponse.ERROR.value: logger.error("Command %s failed with: '%s'", command_str, resp) if value == "03": - error_register = await self.get_error_register() + error_register = await self.request_error_register() await self.reset_error_register() raise CytomatTelegramStructureError(f"Telegram structure error: {error_register}") if int(value, base=16) in error_map: @@ -253,7 +253,7 @@ def _site_to_firmware_string(self, site: PlateHolder) -> str: raise ValueError(f"Unsupported Cytomat model: {self.model}") - async def get_overview_register(self) -> OverviewRegisterState: + async def request_overview_register(self) -> OverviewRegisterState: num_tries = 10 for _ in range(num_tries): try: @@ -263,9 +263,9 @@ async def get_overview_register(self) -> OverviewRegisterState: continue return OverviewRegisterState.from_resp(resp) await self.reset_error_register() - raise CytomatCommandUnknownError("Could not get overview register") + raise CytomatCommandUnknownError("Could not request overview register") - async def get_warning_register(self) -> WarningRegister: + async def request_warning_register(self) -> WarningRegister: hex_value = await self.send_command("ch", "bw", "") for member in WarningRegister: if hex_value == member.value: @@ -273,7 +273,7 @@ async def get_warning_register(self) -> WarningRegister: await self.reset_error_register() raise Exception(f"Unknown warning register value: {hex_value}") - async def get_error_register(self) -> ErrorRegister: + async def request_error_register(self) -> ErrorRegister: hex_value = await self.send_command("ch", "be", "") for member in ErrorRegister: if hex_value == member.value: @@ -299,7 +299,7 @@ async def shovel_in(self): async def shovel_out(self): return await self.send_action("ll", "sp", "002") - async def get_action_register(self) -> ActionRegisterState: + async def request_action_register(self) -> ActionRegisterState: hex_value = await self.send_command("ch", "ba", "") binary_repr = hex_to_binary(hex_value) target, action = binary_repr[:3], binary_repr[3:] @@ -320,7 +320,7 @@ async def get_action_register(self) -> ActionRegisterState: return ActionRegisterState(target=target_enum, action=action_enum) - async def get_swap_register(self) -> SwapStationState: + async def request_swap_register(self) -> SwapStationState: value = await self.send_command("ch", "sw", "") return SwapStationState( position=SwapStationPosition(int(value[0])), @@ -328,7 +328,7 @@ async def get_swap_register(self) -> SwapStationState: load_status_at_processor=LoadStatusAtProcessor(int(value[2])), ) - async def get_sensor_register(self) -> SensorStates: + async def request_sensor_register(self) -> SensorStates: hex_value = await self.send_command("ch", "ts", "") binary_values = hex_to_base_twelve(hex_value) return SensorStates( @@ -387,16 +387,16 @@ async def action_read_barcode( async def wait_for_transfer_station(self, occupied: bool = False): """Wait for the transfer station to be occupied, or unoccupied.""" - while (await self.get_overview_register()).transfer_station_occupied != occupied: + while (await self.request_overview_register()).transfer_station_occupied != occupied: await asyncio.sleep(1) async def wait_for_task_completion(self, timeout=60) -> OverviewRegisterState: start = time.time() while True: - overview_register = await self.get_overview_register() + overview_register = await self.request_overview_register() if not overview_register.busy_bit_set: if overview_register.error_register_set: - error_register = await self.get_error_register() + error_register = await self.request_error_register() await self.reset_error_register() raise error_register_map[error_register] return overview_register @@ -414,7 +414,7 @@ async def set_shaking_frequency( assert all(shaker in [1, 2] for shaker in shakers), "Shaker index must be 1 or 2" return [await self.send_command("se", f"pb 2{idx - 1}", f"{frequency:04}") for idx in shakers] - async def get_incubation_query( + async def request_incubation_query( self, query: Literal["ic", "ih", "io", "it"] ) -> CytomatIncupationResponse: resp = await self.send_command("ch", query, "") @@ -423,17 +423,17 @@ async def get_incubation_query( nominal_value=float(nominal.lstrip("+")), actual_value=float(actual.lstrip("+")) ) - async def get_co2(self) -> CytomatIncupationResponse: - return await self.get_incubation_query("ic") + async def request_co2(self) -> CytomatIncupationResponse: + return await self.request_incubation_query("ic") - async def get_humidity(self) -> CytomatIncupationResponse: - return await self.get_incubation_query("ih") + async def request_humidity(self) -> CytomatIncupationResponse: + return await self.request_incubation_query("ih") - async def get_o2(self) -> CytomatIncupationResponse: - return await self.get_incubation_query("io") + async def request_o2(self) -> CytomatIncupationResponse: + return await self.request_incubation_query("io") - async def get_temperature(self) -> float: - return (await self.get_incubation_query("it")).actual_value + async def request_temperature(self) -> float: + return (await self.request_incubation_query("it")).actual_value def serialize(self) -> dict: return { diff --git a/pylabrobot/thermo_fisher/cytomat/chatterbox.py b/pylabrobot/thermo_fisher/cytomat/chatterbox.py index 4eaf49f6f3b..d1a5ea8ac1f 100644 --- a/pylabrobot/thermo_fisher/cytomat/chatterbox.py +++ b/pylabrobot/thermo_fisher/cytomat/chatterbox.py @@ -17,4 +17,4 @@ async def send_command(self, command_type, command, params): return "0" * 8 async def wait_for_transfer_station(self, occupied: bool = False): - _ = await self.get_overview_register() + _ = await self.request_overview_register() diff --git a/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py index 3f4c40e9fdf..24eb0d5dcc7 100644 --- a/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py +++ b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py @@ -142,7 +142,7 @@ def supports_active_cooling(self) -> bool: async def set_temperature(self, temperature: float): raise NotImplementedError("Temperature control not implemented yet") - async def get_current_temperature(self) -> float: + async def request_current_temperature(self) -> float: raise NotImplementedError("Temperature query not implemented yet") async def deactivate(self): @@ -157,7 +157,7 @@ def supports_humidity_control(self) -> bool: async def set_humidity(self, humidity: float): raise NotImplementedError("Humidity control not implemented yet") - async def get_current_humidity(self) -> float: + async def request_current_humidity(self) -> float: raise NotImplementedError("Humidity query not implemented yet") # -- ShakerBackend -- From fe3e577a666c2d011d4f08f2b605ed1363f0b346 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sat, 28 Mar 2026 23:04:32 -0700 Subject: [PATCH 22/69] Add multi-channel PIP methods, foil piercing, and review fixes Move 14 multi-channel PIP operations to STARPIPBackend: channel positioning (Y/Z), initialization, spread, z-safety, foil piercing. Parameters use mm (PLR standard) with internal 0.1mm conversion. Key changes: - pierce_foil and step_off_foil on STARPIPBackend with explicit deck param - iSWAP-parked checks on Y-movement methods - Channel min Y spacing queried from firmware in driver setup() - Right X-arm conditional on right_x_drive_large - Wash station conditional on wash_station_*_installed - Legacy backend aliases (left_x_arm, iswap) for PIPBackend compat - Fixed pierce_foil one_by_one bug (z vs z+distance_from_bottom) - Fixed _ensure_can_reach_position dead fallback (is None vs not) - Architecture doc updated Co-Authored-By: Claude Opus 4.6 (1M context) --- .../liquid_handlers/star/chatterbox.py | 7 +- .../hamilton/liquid_handlers/star/driver.py | 96 +- .../hamilton/liquid_handlers/star/iswap.py | 472 +++++++++- .../liquid_handlers/star/misc/architecture.md | 35 +- .../liquid_handlers/star/pip_backend.py | 513 ++++++++++- .../liquid_handlers/star/tests/iswap_tests.py | 8 +- .../backends/hamilton/STAR_backend.py | 847 +++--------------- 7 files changed, 1234 insertions(+), 744 deletions(-) diff --git a/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py index 7ee08e399a8..d60aa406fdd 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py +++ b/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py @@ -58,6 +58,8 @@ async def setup(self): self.pip = STARPIPBackend(self) + self._channels_minimum_y_spacing = [9.0] * self._num_channels + if self.extended_conf.left_x_drive.core_96_head_installed: from .head96_backend import STARHead96Backend @@ -66,9 +68,9 @@ async def setup(self): self.head96 = None if self.extended_conf.left_x_drive.iswap_installed: - from .iswap import iSWAP + from .iswap import iSWAPBackend - self.iswap = iSWAP(driver=self) + self.iswap = iSWAPBackend(driver=self) self.iswap._version = "chatterbox" self.iswap._parked = True else: @@ -107,6 +109,7 @@ async def setup(self): async def stop(self): self.machine_conf = None self.extended_conf = None + self._channels_minimum_y_spacing = [] self.head96 = None self.iswap = None self.autoload = None diff --git a/pylabrobot/hamilton/liquid_handlers/star/driver.py b/pylabrobot/hamilton/liquid_handlers/star/driver.py index 22469a53602..52ebb305b77 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/star/driver.py @@ -1,10 +1,12 @@ """STARDriver: inherits HamiltonLiquidHandler, adds STAR-specific config and error handling.""" +import asyncio import datetime import enum +import math import re from dataclasses import dataclass, field -from typing import Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, List, Optional from pylabrobot.capabilities.liquid_handling.head96_backend import Head96Backend from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend @@ -16,6 +18,13 @@ ) from pylabrobot.resources.hamilton import TipPickupMethod, TipSize +if TYPE_CHECKING: + from .autoload import STARAutoload + from .cover import STARCover + from .iswap import iSWAPBackend + from .wash_station import STARWashStation + from .x_arm import STARXArm + # --------------------------------------------------------------------------- # Configuration dataclasses @@ -131,6 +140,7 @@ def __init__( # Populated during setup(). self.machine_conf: Optional[MachineConfiguration] = None self.extended_conf: Optional[ExtendedConfiguration] = None + self._channels_minimum_y_spacing: List[float] = [] self.pip: PIPBackend # set in setup() self.head96: Optional[Head96Backend] = None # set in setup() if installed self.iswap: Optional["iSWAPBackend"] = None # set in setup() if installed @@ -223,6 +233,8 @@ async def setup(self): self.pip = STARPIPBackend(self) + self._channels_minimum_y_spacing = await self.channels_request_y_minimum_spacing() + if self.extended_conf.left_x_drive.core_96_head_installed: from .head96_backend import STARHead96Backend @@ -231,9 +243,9 @@ async def setup(self): self.head96 = None if self.extended_conf.left_x_drive.iswap_installed: - from .iswap import iSWAP + from .iswap import iSWAPBackend - self.iswap = iSWAP(driver=self) + self.iswap = iSWAPBackend(driver=self) else: self.iswap = None @@ -272,6 +284,7 @@ async def stop(self): await super().stop() self.machine_conf = None self.extended_conf = None + self._channels_minimum_y_spacing = [] self.head96 = None self.iswap = None self.autoload = None @@ -989,3 +1002,80 @@ async def set_instrument_configuration( async def pre_initialize_instrument(self): """Pre-initialize instrument""" return await self.send_command(module="C0", command="VI", read_timeout=300) + + # -- PIP channel helpers --------------------------------------------------- + + y_drive_mm_per_increment = 0.046302082 + + @staticmethod + def channel_id(channel_idx: int) -> str: + """Return the firmware module identifier for a PIP channel. + + Args: + channel_idx: 0-indexed channel index (0 = backmost). + + Returns: + Module string like ``"P1"`` ... ``"PG"``. + """ + channel_ids = "123456789ABCDEFG" + return "P" + channel_ids[channel_idx] + + @staticmethod + def y_drive_increment_to_mm(value_increments: int) -> float: + """Convert Y-axis hardware increments to mm.""" + return round(value_increments * STARDriver.y_drive_mm_per_increment, 2) + + async def channel_request_y_minimum_spacing(self, channel_idx: int) -> float: + """Query the minimum Y spacing for a single channel. + + Args: + channel_idx: 0-indexed channel index. + + Returns: + The minimum Y spacing in mm. + """ + if not 0 <= channel_idx <= self.num_channels - 1: + raise ValueError( + f"channel_idx must be between 0 and {self.num_channels - 1}, got {channel_idx}." + ) + + resp = await self.send_command( + module=self.channel_id(channel_idx), + command="VY", + fmt="yc### (n)", + ) + return self.y_drive_increment_to_mm(resp["yc"][1]) + + async def channels_request_y_minimum_spacing(self) -> List[float]: + """Query the minimum Y spacing for all channels in parallel. + + Returns: + A list of minimum Y spacings in mm, one per channel. + """ + return list( + await asyncio.gather( + *( + self.channel_request_y_minimum_spacing(channel_idx=idx) + for idx in range(self.num_channels) + ) + ) + ) + + def _min_spacing_between(self, i: int, j: int) -> float: + """Return the conservative minimum Y spacing required between channels *i* and *j*. + + For adjacent channels, the constraint is the larger of the two channels' individual minimum + spacings, ceiling'd to 1 decimal place for safe movement. + + For non-adjacent channels, the spacing is the sum of all intermediate adjacent-pair spacings. + """ + if not self._channels_minimum_y_spacing: + if self.extended_conf is not None: + return abs(j - i) * self.extended_conf.min_raster_pitch_pip_channels + return abs(j - i) * 9.0 + + lo, hi = min(i, j), max(i, j) + if hi - lo == 1: + spacing = max(self._channels_minimum_y_spacing[lo], self._channels_minimum_y_spacing[hi]) + return math.ceil(spacing * 10) / 10 + return sum(self._min_spacing_between(k, k + 1) for k in range(lo, hi)) diff --git a/pylabrobot/hamilton/liquid_handlers/star/iswap.py b/pylabrobot/hamilton/liquid_handlers/star/iswap.py index 7eb5a140ec8..704df06df6c 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/iswap.py +++ b/pylabrobot/hamilton/liquid_handlers/star/iswap.py @@ -1,11 +1,14 @@ +import enum +from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import Optional, cast +from typing import Literal, Optional, cast from pylabrobot.arms.backend import OrientableGripperArmBackend -from pylabrobot.arms.standard import GripperLocation +from pylabrobot.arms.standard import GripDirection, GripperLocation from pylabrobot.capabilities.capability import BackendParams from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver from pylabrobot.resources import Coordinate +from pylabrobot.resources.rotation import Rotation def _direction_degrees_to_grip_direction(degrees: float) -> int: @@ -21,7 +24,20 @@ def _direction_degrees_to_grip_direction(degrees: float) -> int: return mapping[normalized] -class iSWAP(OrientableGripperArmBackend): +class iSWAPBackend(OrientableGripperArmBackend): + + class RotationDriveOrientation(enum.Enum): + LEFT = 1 + FRONT = 2 + RIGHT = 3 + PARKED_RIGHT = None + + class WristDriveOrientation(enum.Enum): + RIGHT = 1 + STRAIGHT = 2 + LEFT = 3 + REVERSE = 4 + def __init__(self, driver: STARDriver): self.driver = driver self._version: Optional[str] = None @@ -39,7 +55,20 @@ def parked(self) -> bool: return self._parked is True async def request_gripper_location(self, backend_params=None) -> GripperLocation: - raise NotImplementedError("iSWAP does not support request_gripper_location") + """Request iSWAP grip center position (C0 QG). + + Returns: + GripperLocation with position in mm and a default rotation. + """ + resp = await self.driver.send_command( + module="C0", command="QG", fmt="xs#####xd#yj####yd#zj####zd#" + ) + location = Coordinate( + x=(resp["xs"] / 10) * (1 if resp["xd"] == 0 else -1), + y=(resp["yj"] / 10) * (1 if resp["yd"] == 0 else -1), + z=(resp["zj"] / 10) * (1 if resp["zd"] == 0 else -1), + ) + return GripperLocation(location=location, rotation=Rotation()) async def _on_setup(self) -> None: if self._version is None: @@ -49,6 +78,421 @@ async def _request_version(self) -> str: """Request the iSWAP firmware version from the device.""" return cast(str, (await self.driver.send_command("R0", "RF", fmt="rf" + "&" * 15))["rf"]) + async def initialize(self) -> None: + """Initialize iSWAP (C0 FI). For standalone configuration only.""" + await self.driver.send_command(module="C0", command="FI") + + async def open_not_initialized_gripper(self) -> None: + """Open gripper when iSWAP is not yet initialized (C0 GI).""" + await self.driver.send_command(module="C0", command="GI") + + async def dangerous_release_brake(self) -> None: + """Release the iSWAP brake (R0 BA). Use with caution.""" + await self.driver.send_command(module="R0", command="BA") + + async def reengage_brake(self) -> None: + """Re-engage the iSWAP brake (R0 BO).""" + await self.driver.send_command(module="R0", command="BO") + + async def initialize_z_axis(self) -> None: + """Initialize the iSWAP Z axis (R0 ZI).""" + await self.driver.send_command(module="R0", command="ZI") + + # -- relative / absolute movement ------------------------------------------ + + async def move_x_relative(self, step_size: float, allow_splitting: bool = False) -> None: + """Move iSWAP X by a relative step (C0 GX). + + Args: + step_size: X step size [mm]. Between -99.9 and 99.9 unless allow_splitting is True. + allow_splitting: Allow splitting into multiple firmware commands. + """ + direction = 0 if step_size >= 0 else 1 + max_step = 99.9 + if abs(step_size) > max_step: + if not allow_splitting: + raise ValueError("step_size must be between -99.9 and 99.9") + first = max_step if step_size > 0 else -max_step + await self.move_x_relative(step_size=first, allow_splitting=True) + remaining = step_size - first + return await self.move_x_relative(remaining, allow_splitting=True) + + await self.driver.send_command( + module="C0", command="GX", + gx=f"{round(abs(step_size) * 10):03}", + xd=direction, + ) + + async def move_y_relative(self, step_size: float, allow_splitting: bool = False) -> None: + """Move iSWAP Y by a relative step (C0 GY). + + Args: + step_size: Y step size [mm]. Between -99.9 and 99.9 unless allow_splitting is True. + allow_splitting: Allow splitting into multiple firmware commands. + """ + direction = 0 if step_size >= 0 else 1 + max_step = 99.9 + if abs(step_size) > max_step: + if not allow_splitting: + raise ValueError("step_size must be between -99.9 and 99.9") + first = max_step if step_size > 0 else -max_step + await self.move_y_relative(step_size=first, allow_splitting=True) + remaining = step_size - first + return await self.move_y_relative(remaining, allow_splitting=True) + + await self.driver.send_command( + module="C0", command="GY", + gy=f"{round(abs(step_size) * 10):03}", + yd=direction, + ) + + async def move_z_relative(self, step_size: float, allow_splitting: bool = False) -> None: + """Move iSWAP Z by a relative step (C0 GZ). + + Args: + step_size: Z step size [mm]. Between -99.9 and 99.9 unless allow_splitting is True. + allow_splitting: Allow splitting into multiple firmware commands. + """ + direction = 0 if step_size >= 0 else 1 + max_step = 99.9 + if abs(step_size) > max_step: + if not allow_splitting: + raise ValueError("step_size must be between -99.9 and 99.9") + first = max_step if step_size > 0 else -max_step + await self.move_z_relative(step_size=first, allow_splitting=True) + remaining = step_size - first + return await self.move_z_relative(remaining, allow_splitting=True) + + await self.driver.send_command( + module="C0", command="GZ", + gz=f"{round(abs(step_size) * 10):03}", + zd=direction, + ) + + async def request_in_parking_position(self) -> dict: + """Request iSWAP parking position status (C0 RG). + + Returns: + Parsed response dict with key ``"rg"`` (0 = not parked, 1 = parked). + """ + return await self.driver.send_command(module="C0", command="RG", fmt="rg#") + + async def request_initialization_status(self) -> bool: + """Request iSWAP initialization status (R0 QW). + + Returns: + True if iSWAP is fully initialized. + """ + resp = await self.driver.send_command(module="R0", command="QW", fmt="qw#") + return cast(int, resp["qw"]) == 1 + + async def rotation_drive_request_y(self) -> float: + """Request iSWAP rotation drive Y position (center) in mm (R0 RY). + + This is equivalent to the Y location of the iSWAP module. + """ + if not self.driver.extended_conf.left_x_drive.iswap_installed: + raise RuntimeError("iSWAP is not installed") + resp = await self.driver.send_command(module="R0", command="RY", fmt="ry##### (n)") + iswap_y_pos = resp["ry"][1] # 0 = FW counter, 1 = HW counter + return round(STARDriver.y_drive_increment_to_mm(iswap_y_pos), 1) + + async def move_x(self, x_position: float) -> None: + """Move iSWAP X to an absolute position [mm].""" + loc = (await self.get_gripper_location()).location + await self.move_x_relative(step_size=x_position - loc.x, allow_splitting=True) + + async def move_y(self, y_position: float) -> None: + """Move iSWAP Y to an absolute position [mm].""" + loc = (await self.get_gripper_location()).location + await self.move_y_relative(step_size=y_position - loc.y, allow_splitting=True) + + async def move_z(self, z_position: float) -> None: + """Move iSWAP Z to an absolute position [mm].""" + loc = (await self.get_gripper_location()).location + await self.move_z_relative(step_size=z_position - loc.z, allow_splitting=True) + + # -- rotation / wrist drive ------------------------------------------------ + + async def request_rotation_drive_position_increments(self) -> int: + """Query the iSWAP rotation drive position in increments (R0 RW).""" + response = await self.driver.send_command(module="R0", command="RW", fmt="rw######") + return cast(int, response["rw"]) + + async def request_rotation_drive_orientation(self) -> "iSWAPBackend.RotationDriveOrientation": + """Request the iSWAP rotation drive orientation. + + Uses empirically determined increment values: + FRONT: -25 +/- 50, RIGHT: +29068 +/- 50, LEFT: -29116 +/- 50 + """ + RDO = iSWAPBackend.RotationDriveOrientation + rotation_orientation_to_motor_increment_dict = { + RDO.FRONT: range(-75, 26), + RDO.RIGHT: range(29018, 29119), + RDO.LEFT: range(-29166, -29065), + RDO.PARKED_RIGHT: range(29450, 29550), + } + + motor_position_increments = await self.request_rotation_drive_position_increments() + + for orientation, increment_range in rotation_orientation_to_motor_increment_dict.items(): + if motor_position_increments in increment_range: + return orientation + + raise ValueError( + f"Unknown rotation orientation: {motor_position_increments}. " + f"Expected one of {list(rotation_orientation_to_motor_increment_dict.values())}." + ) + + async def request_wrist_drive_position_increments(self) -> int: + """Query the iSWAP wrist drive position in increments (R0 RT).""" + response = await self.driver.send_command(module="R0", command="RT", fmt="rt######") + return cast(int, response["rt"]) + + async def request_wrist_drive_orientation(self) -> "iSWAPBackend.WristDriveOrientation": + """Request the iSWAP wrist drive orientation. + + The wrist orientation is relative to the rotation drive orientation. + """ + WDO = iSWAPBackend.WristDriveOrientation + wrist_orientation_to_motor_increment_dict = { + WDO.RIGHT: range(-26_627, -26_527), + WDO.STRAIGHT: range(-8_804, -8_704), + WDO.LEFT: range(9_051, 9_151), + WDO.REVERSE: range(26_802, 26_902), + } + + motor_position_increments = await self.request_wrist_drive_position_increments() + + for orientation, increment_range in wrist_orientation_to_motor_increment_dict.items(): + if motor_position_increments in increment_range: + return orientation + + raise ValueError( + f"Unknown wrist orientation: {motor_position_increments}. " + f"Expected one of {list(wrist_orientation_to_motor_increment_dict)}." + ) + + async def rotate( + self, + rotation_drive: "iSWAPBackend.RotationDriveOrientation", + grip_direction: GripDirection, + gripper_velocity: int = 55_000, + gripper_acceleration: int = 170, + gripper_protection: Literal[0, 1, 2, 3, 4, 5, 6, 7] = 5, + wrist_velocity: int = 48_000, + wrist_acceleration: int = 145, + wrist_protection: Literal[0, 1, 2, 3, 4, 5, 6, 7] = 5, + ) -> None: + """Rotate the iSWAP to a predefined position (R0 PD). + + Velocity units are incr/sec. Acceleration units are 1000 incr/sec^2. + """ + assert 20 <= gripper_velocity <= 75_000 + assert 5 <= gripper_acceleration <= 200 + assert 20 <= wrist_velocity <= 65_000 + assert 20 <= wrist_acceleration <= 200 + + RDO = iSWAPBackend.RotationDriveOrientation + position = 0 + + if rotation_drive.value == RDO.LEFT.value: + position += 10 + elif rotation_drive.value == RDO.FRONT.value: + position += 20 + elif rotation_drive.value == RDO.RIGHT.value: + position += 30 + else: + raise ValueError(f"Invalid rotation drive orientation: {rotation_drive}") + + if grip_direction.value == GripDirection.FRONT.value: + position += 1 + elif grip_direction.value == GripDirection.RIGHT.value: + position += 2 + elif grip_direction.value == GripDirection.BACK.value: + position += 3 + elif grip_direction.value == GripDirection.LEFT.value: + position += 4 + else: + raise ValueError("Invalid grip direction") + + await self.driver.send_command( + module="R0", + command="PD", + pd=position, + wv=f"{gripper_velocity:05}", + wr=f"{gripper_acceleration:03}", + ww=gripper_protection, + tv=f"{wrist_velocity:05}", + tr=f"{wrist_acceleration:03}", + tw=wrist_protection, + ) + + async def rotate_rotation_drive( + self, orientation: "iSWAPBackend.RotationDriveOrientation" + ) -> None: + """Rotate the rotation drive to the given orientation (R0 WP).""" + RDO = iSWAPBackend.RotationDriveOrientation + if orientation.value not in {RDO.RIGHT.value, RDO.FRONT.value, RDO.LEFT.value}: + raise ValueError(f"Invalid rotation drive orientation: {orientation}") + await self.driver.send_command( + module="R0", command="WP", auto_id=False, wp=orientation.value, + ) + + async def rotate_wrist(self, orientation: "iSWAPBackend.WristDriveOrientation") -> None: + """Rotate the wrist to the given orientation (R0 TP).""" + await self.driver.send_command( + module="R0", command="TP", auto_id=False, tp=orientation.value, + ) + + # -- collapse, teaching, velocity control ---------------------------------- + + async def collapse_gripper_arm( + self, + minimum_traverse_height: float = 360.0, + fold_up_at_end: bool = False, + ) -> None: + """Collapse / fold the gripper arm (C0 PN). + + Args: + minimum_traverse_height: Minimum traverse height [mm]. 0..360. + fold_up_at_end: Fold-up sequence at end of process. + """ + if not 0 <= minimum_traverse_height <= 360.0: + raise ValueError("minimum_traverse_height must be between 0 and 360.0") + + await self.driver.send_command( + module="C0", + command="PN", + th=round(minimum_traverse_height * 10), + gc=fold_up_at_end, + ) + + async def prepare_teaching( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_direction: int = 0, + z_position: int = 0, + z_direction: int = 0, + location: int = 0, + hotel_depth: int = 1300, + grip_direction: int = 1, + minimum_traverse_height: int = 3600, + collision_control_level: int = 1, + acceleration_index_high_acc: int = 4, + acceleration_index_low_acc: int = 1, + ) -> None: + """Prepare for teaching with iSWAP (C0 PT). + + All position args are in 0.1mm firmware units. + """ + assert 0 <= x_position <= 30000 + assert 0 <= x_direction <= 1 + assert 0 <= y_position <= 6500 + assert 0 <= y_direction <= 1 + assert 0 <= z_position <= 3600 + assert 0 <= z_direction <= 1 + assert 0 <= location <= 1 + assert 0 <= hotel_depth <= 3000 + assert 0 <= minimum_traverse_height <= 3600 + assert 0 <= collision_control_level <= 1 + assert 0 <= acceleration_index_high_acc <= 4 + assert 0 <= acceleration_index_low_acc <= 4 + + await self.driver.send_command( + module="C0", + command="PT", + xs=f"{x_position:05}", + xd=x_direction, + yj=f"{y_position:04}", + yd=y_direction, + zj=f"{z_position:04}", + zd=z_direction, + hh=location, + hd=f"{hotel_depth:04}", + gr=grip_direction, + th=f"{minimum_traverse_height:04}", + ga=collision_control_level, + xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", + ) + + async def get_logic_position( + self, + x_position: int = 0, + x_direction: int = 0, + y_position: int = 0, + y_direction: int = 0, + z_position: int = 0, + z_direction: int = 0, + location: int = 0, + hotel_depth: int = 1300, + grip_direction: int = 1, + collision_control_level: int = 1, + ) -> None: + """Get logic iSWAP position (C0 PC). + + All position args are in 0.1mm firmware units. + """ + assert 0 <= x_position <= 30000 + assert 0 <= x_direction <= 1 + assert 0 <= y_position <= 6500 + assert 0 <= y_direction <= 1 + assert 0 <= z_position <= 3600 + assert 0 <= z_direction <= 1 + assert 0 <= location <= 1 + assert 0 <= hotel_depth <= 3000 + assert 1 <= grip_direction <= 4 + assert 0 <= collision_control_level <= 1 + + await self.driver.send_command( + module="C0", + command="PC", + xs=x_position, + xd=x_direction, + yj=y_position, + yd=y_direction, + zj=z_position, + zd=z_direction, + hh=location, + hd=hotel_depth, + gr=grip_direction, + ga=collision_control_level, + ) + + # -- R0 parameter helpers (private) ---------------------------------------- + + async def _get_r0_parameter(self, name: str, fmt: str): + """Read a single R0 parameter via RA command.""" + return (await self.driver.send_command("R0", "RA", ra=name, fmt=fmt))[name] + + async def _set_r0_parameter(self, **kwargs) -> None: + """Set R0 parameter(s) via AA command.""" + await self.driver.send_command("R0", "AA", **kwargs) + + @asynccontextmanager + async def slow(self, wrist_velocity: int = 20_000, gripper_velocity: int = 20_000): + """Context manager that temporarily slows iSWAP wrist and gripper velocities (R0 RA/AA). + + Args: + wrist_velocity: Wrist velocity in incr/sec (20..65000). + gripper_velocity: Gripper velocity in incr/sec (20..75000). + """ + assert 20 <= gripper_velocity <= 75_000 + assert 20 <= wrist_velocity <= 65_000 + + original_wv = await self._get_r0_parameter("wv", "wv#####") + original_tv = await self._get_r0_parameter("tv", "tv#####") + + await self._set_r0_parameter(wv=gripper_velocity) + await self._set_r0_parameter(tv=wrist_velocity) + try: + yield + finally: + await self._set_r0_parameter(wv=original_wv) + await self._set_r0_parameter(tv=original_tv) + @dataclass class ParkParams(BackendParams): minimum_traverse_height: float = 280.0 @@ -59,8 +503,8 @@ async def park(self, backend_params: Optional[BackendParams] = None) -> None: Args: backend_params: iSWAP.ParkParams with minimum_traverse_height. """ - if not isinstance(backend_params, iSWAP.ParkParams): - backend_params = iSWAP.ParkParams() + if not isinstance(backend_params, iSWAPBackend.ParkParams): + backend_params = iSWAPBackend.ParkParams() if not 0 <= backend_params.minimum_traverse_height <= 360.0: raise ValueError("minimum_traverse_height must be between 0 and 360.0") @@ -102,8 +546,8 @@ async def close_gripper( gripper_width: Plate width [mm]. backend_params: iSWAP.CloseGripperParams with grip_strength and plate_width_tolerance. """ - if not isinstance(backend_params, iSWAP.CloseGripperParams): - backend_params = iSWAP.CloseGripperParams() + if not isinstance(backend_params, iSWAPBackend.CloseGripperParams): + backend_params = iSWAPBackend.CloseGripperParams() if not 0 <= backend_params.grip_strength <= 9: raise ValueError("grip_strength must be between 0 and 9") @@ -146,8 +590,8 @@ async def pick_up_at_location( resource_width: Plate width [mm]. backend_params: iSWAP.PickUpParams for firmware-specific settings. """ - if not isinstance(backend_params, iSWAP.PickUpParams): - backend_params = iSWAP.PickUpParams() + if not isinstance(backend_params, iSWAPBackend.PickUpParams): + backend_params = iSWAPBackend.PickUpParams() open_gripper_position = resource_width + 3.0 plate_width_for_firmware = round(resource_width * 10) - 33 @@ -224,8 +668,8 @@ async def drop_at_location( resource_width: Plate width [mm]. Used to compute open gripper position. backend_params: iSWAP.DropParams for firmware-specific settings. """ - if not isinstance(backend_params, iSWAP.DropParams): - backend_params = iSWAP.DropParams() + if not isinstance(backend_params, iSWAPBackend.DropParams): + backend_params = iSWAPBackend.DropParams() open_gripper_position = resource_width + 3.0 @@ -297,8 +741,8 @@ async def move_to_location( direction: Grip direction in degrees (0=front, 90=right, 180=back, 270=left). backend_params: iSWAP.MoveToLocationParams for firmware-specific settings. """ - if not isinstance(backend_params, iSWAP.MoveToLocationParams): - backend_params = iSWAP.MoveToLocationParams() + if not isinstance(backend_params, iSWAPBackend.MoveToLocationParams): + backend_params = iSWAPBackend.MoveToLocationParams() if not 0 <= abs(location.x) <= 3000.0: raise ValueError("x_position must be between -3000.0 and 3000.0") diff --git a/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md b/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md index 2390077e54b..6a694985174 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md +++ b/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md @@ -8,7 +8,9 @@ Split the monolithic `STARBackend` (~12k lines) into the new Driver + Capability - PIP and Head96 (capability backends) - iSWAP and CoRe gripper (arm backends) - AutoLoad, Cover, X-Arms, Wash Station (plain helper classes on the driver) +- Multi-channel PIP operations: channel positioning, initialization, foil piercing (on STARPIPBackend) - ~44 generic driver infrastructure methods (firmware queries, EEPROM, area reservation, configuration) +- Channel minimum Y spacing query and enforcement ## Layers @@ -70,9 +72,9 @@ class STARDriver(HamiltonLiquidHandler): # Plain subsystems autoload: Optional[STARAutoload] = None # if autoload installed left_x_arm: Optional[STARXArm] = None # always present - right_x_arm: Optional[STARXArm] = None # always present + right_x_arm: Optional[STARXArm] = None # if right X-drive installed cover: Optional[STARCover] = None # always present - wash_station: Optional[STARWashStation] = None # always present + wash_station: Optional[STARWashStation] = None # if wash station installed ``` ### Responsibilities @@ -80,7 +82,8 @@ class STARDriver(HamiltonLiquidHandler): - **USB I/O**: Connect/disconnect via `pylabrobot.io.usb.USB`. Background reading thread for async command/response matching. - **Firmware protocol**: `send_command(module, command, **params)` assembles the STAR text protocol, sends it, waits for matching response, parses it. - **Machine configuration**: On `setup()`, queries `RM` (machine config) and `QM` (extended config) to discover installed hardware. Stores as `self.machine_conf` and `self.extended_conf`. -- **Backend/subsystem creation**: During `setup()`, creates backends based on discovered config. Conditional for autoload (needs `auto_load_installed`), Head96 (needs `core_96_head_installed`), iSWAP (needs `iswap_installed`). Unconditional for PIP, X-arms, cover, wash station. +- **Backend/subsystem creation**: During `setup()`, creates backends based on discovered config. Conditional for autoload (`auto_load_installed`), Head96 (`core_96_head_installed`), iSWAP (`iswap_installed`), right X-arm (`right_x_drive_large`), wash station (`wash_station_*_installed`). Unconditional for PIP, left X-arm, cover. +- **Channel spacing**: Queries per-channel minimum Y spacing from firmware during `setup()` and stores in `_channels_minimum_y_spacing`. Used by `_min_spacing_between()` for collision-safe channel positioning. - **Generic instrument operations**: Firmware queries, EEPROM read/write, runtime control (halt, single-step), area reservation, instrument configuration. ~44 methods directly on the driver. - **Tip type registration**: `_tth2tti` mapping shared across PIP and Head96. - **Error parsing**: Firmware error codes → Python exceptions. @@ -167,7 +170,13 @@ Methods: `request_settings(station)`, `initialize_valves(station)`, `fill_chambe Implements `PIPBackend`. Translates PIP operations into STAR firmware commands. -Key methods: `pick_up_tips`, `drop_tips`, `aspirate`, `dispense`. +Key methods: +- **Liquid handling**: `pick_up_tips`, `drop_tips`, `aspirate`, `dispense` +- **Channel positioning**: `position_channels_in_y_direction`, `position_channels_in_z_direction`, `get_channels_y_positions`, `get_channels_z_positions`, `move_all_pipetting_channels_to_defined_position`, `position_max_free_y_for_n`, `move_all_channels_in_z_safety`, `spread_pip_channels`, `move_channel_z` +- **Initialization**: `initialize_pip`, `initialize_pipetting_channels` +- **Foil operations**: `pierce_foil(deck=...)`, `step_off_foil(deck=...)` + +Methods that move channels in Y check `self._driver.iswap` and park it if needed. Parameters use mm (PLR standard); conversion to 0.1mm firmware units is done internally. ### STARHead96Backend @@ -212,7 +221,9 @@ await star.setup() │ 2. Query RM → MachineConfiguration │ 3. Query QM → ExtendedConfiguration │ 4. Create backends: pip (always), head96 (if installed), iswap (if installed) - │ 5. Create subsystems: autoload (if installed), x_arms, cover, wash_station (if installed) + │ 5. Create subsystems: autoload (if installed), left x_arm, right x_arm (if installed), + │ cover, wash_station (if installed) + │ 6. Query per-channel minimum Y spacing │ ├─ Wire capability frontends to backends (on STAR device) │ self.pip = PIP(backend=driver.pip) @@ -237,7 +248,7 @@ On the driver: star._driver.autoload # None if no autoload module star._driver.wash_station # None if no wash station star._driver.left_x_arm # always present -star._driver.right_x_arm # always present +star._driver.right_x_arm # None if no right X-drive star._driver.cover # always present ``` @@ -279,6 +290,10 @@ self._new_cover = STARCover(driver=self) self._new_left_x_arm = STARXArm(driver=self, side="left") self._new_right_x_arm = STARXArm(driver=self, side="right") self._new_wash_station = STARWashStation(driver=self) + +# Public aliases so STARPIPBackend (which sees self as its driver) can access these. +self.left_x_arm = self._new_left_x_arm +self.iswap = None # legacy handles iSWAP parking via @need_iswap_parked decorator ``` Migrated methods delegate to these instances (with Carrier→int conversion where needed). All delegating methods have one-line deprecation docstrings: @@ -287,14 +302,20 @@ Migrated methods delegate to these instances (with Carrier→int conversion wher async def park_autoload(self): """Deprecated: use ``star.autoload.park()``.""" return await self._new_autoload.park() + +async def pierce_foil(self, wells, ...): + """Deprecated: use ``star.pip.backend.pierce_foil()``.""" + await self._new_pip.pierce_foil(wells=wells, ..., deck=self.deck) ``` Generic driver methods (firmware queries, EEPROM, etc.) exist on both `STARDriver` and the legacy `STARBackend` — the legacy versions have deprecation docstrings but keep their original implementation bodies unchanged. +The `left_x_arm` and `iswap` public aliases are needed because `STARPIPBackend` accesses `self._driver.left_x_arm` (for x-arm movement in `pierce_foil`) and `self._driver.iswap` (for iSWAP-parked checks). Since the legacy backend passes `self` as the driver to `STARPIPBackend`, these attributes must exist. `iswap = None` means the iSWAP park check safely no-ops on the legacy path (legacy handles it via its own `@need_iswap_parked` decorator). + ## What stays in legacy - Probing/LLD: `probe_liquid_heights`, CLLD/PLLD methods -- Foil piercing: `pierce_foil`, `step_off_foil` - Hotel mode: `put_in_hotel`, `get_from_hotel` - Heater-shaker: HHC temperature control methods +- Single-channel positioning: `move_channel_y`, `position_single_pipetting_channel_in_y/z_direction` - Some lower-level PIP/Head96/iSWAP firmware commands not yet migrated to backends diff --git a/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py index 80f959429f3..5b3fc9c711a 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py @@ -5,16 +5,20 @@ import enum import logging from dataclasses import dataclass -from typing import TYPE_CHECKING, List, Optional, Sequence, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Sequence, Tuple, Union from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense, Pickup, TipDrop +from pylabrobot.capabilities.liquid_handling.utils import ( + get_tight_single_resource_liquid_op_offsets, + get_wide_single_resource_liquid_op_offsets, +) from pylabrobot.legacy.liquid_handling.liquid_classes.hamilton import ( HamiltonLiquidClass, get_star_liquid_class, ) -from pylabrobot.resources import Tip, TipSpot, Well +from pylabrobot.resources import Resource, Tip, TipSpot, Well from pylabrobot.resources.hamilton import HamiltonTip, TipDropMethod, TipPickupMethod, TipSize from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_backend import ( STARFirmwareError, @@ -191,9 +195,8 @@ def num_channels(self) -> int: async def _ensure_iswap_parked(self) -> None: """Park the iSWAP if it is installed and not already parked.""" - iswap = getattr(self._driver, 'iswap', None) - if iswap is not None and hasattr(iswap, 'parked') and not iswap.parked: - await iswap.park() + if self._driver.iswap is not None and not self._driver.iswap.parked: + await self._driver.iswap.park() def _ensure_can_reach_position( self, @@ -205,8 +208,8 @@ def _ensure_can_reach_position( if self._driver.extended_conf is None: return # skip validation if config not available (e.g. chatterbox) ext = self._driver.extended_conf - spacings = getattr(self._driver, '_channels_minimum_y_spacing', None) - if spacings is None: + spacings = self._driver._channels_minimum_y_spacing + if not spacings: spacings = [ext.min_raster_pitch_pip_channels] * self.num_channels cant_reach = [] @@ -1032,3 +1035,499 @@ def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: if tip.tip_size in {TipSize.XL}: return False return True + + # -- multi-channel PIP operations ------------------------------------------ + + async def spread_pip_channels(self): + """Spread PIP channels (C0:JE).""" + return await self._driver.send_command(module="C0", command="JE") + + async def move_all_channels_in_z_safety(self): + """Move all pipetting channels to Z-safety position (C0:ZA).""" + return await self._driver.send_command(module="C0", command="ZA") + + async def position_max_free_y_for_n(self, pipetting_channel_index: int): + """Position all pipetting channels so that there is maximum free Y range for channel n (C0:JP). + + Args: + pipetting_channel_index: Index of pipetting channel. Must be between 0 and num_channels - 1. + """ + if self._driver.iswap is not None and not self._driver.iswap.parked: + await self._driver.iswap.park() + + assert 0 <= pipetting_channel_index < self.num_channels, ( + "pipetting_channel_index must be between 0 and num_channels - 1" + ) + # convert Python's 0-based indexing to Hamilton firmware's 1-based indexing + pipetting_channel_index_fw = pipetting_channel_index + 1 + + return await self._driver.send_command( + module="C0", + command="JP", + pn=f"{pipetting_channel_index_fw:02}", + ) + + async def move_all_pipetting_channels_to_defined_position( + self, + tip_pattern: bool = True, + x_positions: float = 0.0, + y_positions: float = 0.0, + minimum_traverse_height_at_beginning_of_command: float = 360.0, + z_endpos: float = 0.0, + ): + """Move all pipetting channels to defined position (C0:JM). + + Args: + tip_pattern: Tip pattern (channels involved). Default True. + x_positions: x positions [mm]. Must be between 0 and 2500. Default 0. + y_positions: y positions [mm]. Must be between 0 and 650. Default 0. + minimum_traverse_height_at_beginning_of_command: Minimum traverse height at beginning of a + command [mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be + between 0 and 360. Default 360. + z_endpos: Z-Position at end of a command [mm] (refers to all channels independent of tip + pattern parameter 'tm'). Must be between 0 and 360. Default 0. + """ + + if self._driver.iswap is not None and not self._driver.iswap.parked: + await self._driver.iswap.park() + + assert 0 <= x_positions <= 2500, "x_positions must be between 0 and 2500" + assert 0 <= y_positions <= 650, "y_positions must be between 0 and 650" + assert 0 <= minimum_traverse_height_at_beginning_of_command <= 360, ( + "minimum_traverse_height_at_beginning_of_command must be between 0 and 360" + ) + assert 0 <= z_endpos <= 360, "z_endpos must be between 0 and 360" + + return await self._driver.send_command( + module="C0", + command="JM", + tm=tip_pattern, + xp=round(x_positions * 10), + yp=round(y_positions * 10), + th=round(minimum_traverse_height_at_beginning_of_command * 10), + zp=round(z_endpos * 10), + ) + + async def get_channels_y_positions(self) -> Dict[int, float]: + """Get the Y position of all channels in mm (C0:RY).""" + resp = await self._driver.send_command( + module="C0", + command="RY", + fmt="ry#### (n)", + ) + y_positions = [round(y / 10, 2) for y in resp["ry"]] + + # sometimes there is (likely) a floating point error and channels are reported to be + # less than their minimum spacing apart (typically 9 mm). (When you set channels using + # position_channels_in_y_direction, it will raise an error.) The minimum y is 6mm, + # so we fix that first (in case that value is misreported). Then, we traverse the + # list in reverse and enforce pairwise minimum spacing. + if self._driver.extended_conf is not None: + min_y = self._driver.extended_conf.left_arm_min_y_position + else: + min_y = 6.0 + + if y_positions[-1] < min_y - 0.2: + raise RuntimeError( + "Channels are reported to be too close to the front of the machine. " + f"The known minimum is {min_y}, which will be fixed automatically for " + f"{min_y - 0.2}=min_spacing. We start with the channel closest to `back_channel`, and make sure the + # channel behind it is at least min_spacing away, updating if needed. + for channel_idx in range(back_channel, 0, -1): + spacing = self._driver._min_spacing_between(channel_idx - 1, channel_idx) + if (channel_locations[channel_idx - 1] - channel_locations[channel_idx]) < spacing: + channel_locations[channel_idx - 1] = channel_locations[channel_idx] + spacing + + # Similarly for the channels to the front of `front_channel`, make sure they are all + # spaced >= min_spacing apart. + for channel_idx in range(front_channel, self._driver.num_channels - 1): + spacing = self._driver._min_spacing_between(channel_idx, channel_idx + 1) + if (channel_locations[channel_idx] - channel_locations[channel_idx + 1]) < spacing: + channel_locations[channel_idx + 1] = channel_locations[channel_idx] - spacing + + # Quick checks before movement. + if channel_locations[0] > 650: + raise ValueError("Channel 0 would hit the back of the robot") + + if channel_locations[self._driver.num_channels - 1] < 6: + raise ValueError("Channel N would hit the front of the robot") + + for i in range(len(channel_locations) - 1): + required = self._driver._min_spacing_between(i, i + 1) + actual = channel_locations[i] - channel_locations[i + 1] + if round(actual * 1000) < round(required * 1000): # compare in um to avoid float issues + raise ValueError( + f"Channels {i} and {i + 1} must be at least {required}mm apart, " + f"but are {actual:.2f}mm apart." + ) + + yp = " ".join([f"{round(y * 10):04}" for y in channel_locations.values()]) + return await self._driver.send_command( + module="C0", + command="JY", + yp=yp, + ) + + async def get_channels_z_positions(self) -> Dict[int, float]: + """Get the Z position of all channels in mm (C0:RZ).""" + resp = await self._driver.send_command( + module="C0", + command="RZ", + fmt="rz#### (n)", + ) + return {channel_idx: round(z / 10, 2) for channel_idx, z in enumerate(resp["rz"])} + + async def position_channels_in_z_direction(self, zs: Dict[int, float]): + """Position channels in the Z direction (C0:JZ). + + Args: + zs: A dictionary mapping channel index to the desired Z position in mm. + """ + channel_locations = await self.get_channels_z_positions() + + for channel_idx, z in zs.items(): + channel_locations[channel_idx] = z + + return await self._driver.send_command( + module="C0", + command="JZ", + zp=[f"{round(z * 10):04}" for z in channel_locations.values()], + ) + + async def initialize_pip(self): + """Wrapper around initialize_pipetting_channels firmware command. + + Computes Y positions and calls initialize_pipetting_channels with default parameters. + """ + dy_01mm = (4050 - 2175) // (self.num_channels - 1) # integer division in 0.1mm, matching legacy + y_positions = [round((4050 - i * dy_01mm) / 10, 1) for i in range(self.num_channels)] + + tip_waste_x = 0.0 + if self._driver.extended_conf is not None: + tip_waste_x = self._driver.extended_conf.tip_waste_x_position + + await self.initialize_pipetting_channels( + x_positions=[tip_waste_x], + y_positions=y_positions, + begin_of_tip_deposit_process=_DEFAULT_TRAVERSAL_HEIGHT, + end_of_tip_deposit_process=122.0, + z_position_at_end_of_a_command=360.0, + tip_pattern=[True] * self.num_channels, + tip_type=4, + discarding_method=0, + ) + + async def initialize_pipetting_channels( + self, + x_positions: Optional[List[float]] = None, + y_positions: Optional[List[float]] = None, + begin_of_tip_deposit_process: float = 0.0, + end_of_tip_deposit_process: float = 0.0, + z_position_at_end_of_a_command: float = 360.0, + tip_pattern: Optional[List[bool]] = None, + tip_type: int = 16, + discarding_method: int = 1, + ): + """Initialize pipetting channels (discard tips) (C0:DI). + + Args: + x_positions: X-Position [mm] (discard position). Must be between 0 and 2500. Default [0]. + y_positions: y-Position [mm] (discard position). Must be between 0 and 650. Default [0]. + begin_of_tip_deposit_process: Begin of tip deposit process (Z-discard range) [mm]. Must be + between 0 and 360. Default 0. + end_of_tip_deposit_process: End of tip deposit process (Z-discard range) [mm]. Must be + between 0 and 360. Default 0. + z_position_at_end_of_a_command: Z-Position at end of a command [mm]. Must be between 0 and + 360. Default 360. + tip_pattern: Tip pattern (channels involved). Default [True]. + tip_type: Tip type (recommended is index of longest tip see command 'TT'). Must be + between 0 and 99. Default 16. + discarding_method: discarding method. 0 = place & shift (tp/ tz = tip cone end height), 1 = + drop (no shift) (tp/ tz = stop disk height). Must be between 0 and 1. Default 1. + """ + + if x_positions is None: + x_positions = [0.0] + if y_positions is None: + y_positions = [0.0] + if tip_pattern is None: + tip_pattern = [True] + + # Convert mm to 0.1mm for firmware + x_positions_fw = [round(x * 10) for x in x_positions] + y_positions_fw = [round(y * 10) for y in y_positions] + begin_fw = round(begin_of_tip_deposit_process * 10) + end_fw = round(end_of_tip_deposit_process * 10) + z_end_fw = round(z_position_at_end_of_a_command * 10) + + assert all(0 <= xp <= 25000 for xp in x_positions_fw), ( + "x_positions must be between 0 and 2500 mm" + ) + assert all(0 <= yp <= 6500 for yp in y_positions_fw), ( + "y_positions must be between 0 and 650 mm" + ) + assert 0 <= begin_fw <= 3600, "begin_of_tip_deposit_process must be between 0 and 360 mm" + assert 0 <= end_fw <= 3600, "end_of_tip_deposit_process must be between 0 and 360 mm" + assert 0 <= z_end_fw <= 3600, "z_position_at_end_of_a_command must be between 0 and 360 mm" + assert 0 <= tip_type <= 99, "tip_type must be between 0 and 99" + assert 0 <= discarding_method <= 1, "discarding_method must be between 0 and 1" + + return await self._driver.send_command( + module="C0", + command="DI", + read_timeout=120, + xp=[f"{xp:05}" for xp in x_positions_fw], + yp=[f"{yp:04}" for yp in y_positions_fw], + tp=f"{begin_fw:04}", + tz=f"{end_fw:04}", + te=f"{z_end_fw:04}", + tm=[f"{tm:01}" for tm in tip_pattern], + tt=f"{tip_type:02}", + ti=discarding_method, + ) + + # -- single-channel movement ------------------------------------------------ + + + async def move_channel_z(self, channel: int, z: float): + """Move a single channel in the Z direction (mm). + + Args: + channel: 0-indexed channel index. + z: Target Z position in mm. + """ + assert 0 <= channel < self._driver.num_channels, \ + f"channel must be between 0 and {self._driver.num_channels - 1}" + assert 0 <= z <= 334.7, "z must be between 0 and 334.7 mm" + + return await self._driver.send_command( + module="C0", + command="KZ", + pn=f"{channel + 1:02}", + zj=f"{round(z * 10):04}", + ) + + # -- foil piercing ---------------------------------------------------------- + + def _get_maximum_minimum_spacing_between_channels(self, use_channels: List[int]) -> float: + """Get the maximum of the set of minimum spacing requirements between the channels being used.""" + sorted_channels = sorted(use_channels) + return max( + self._driver._min_spacing_between(hi, lo) + for hi, lo in zip(sorted_channels[1:], sorted_channels[:-1]) + ) + + async def pierce_foil( + self, + wells: Union[Well, List[Well]], + piercing_channels: List[int], + hold_down_channels: List[int], + move_inwards: float, + deck: Resource, + spread: Literal["wide", "tight"] = "wide", + one_by_one: bool = False, + distance_from_bottom: float = 20.0, + ): + """Pierce the foil of the media source plate at the specified column. Throw away the tips + after piercing because there will be a bit of foil stuck to the tips. Use this method + before aspirating from a foil-sealed plate to make sure the tips are clean and the + aspirations are accurate. + + Args: + wells: Well or wells in the plate to pierce the foil. If multiple wells, they must be on one + column. + piercing_channels: The channels to use for piercing the foil. + hold_down_channels: The channels to use for holding down the plate when moving up the + piercing channels. + move_inwards: mm to move inwards when stepping off the foil. + deck: The deck resource, used to compute absolute positions of wells. + spread: The spread of the piercing channels in the well. + one_by_one: If True, the channels will pierce the foil one by one. If False, all channels + will pierce the foil simultaneously. + distance_from_bottom: mm above the cavity bottom to position the piercing channels. + """ + + x: float + ys: List[float] + z: float + + # if only one well is given, but in a list, convert to Well so we fall into single-well logic. + if isinstance(wells, list) and len(wells) == 1: + wells = wells[0] + + if isinstance(wells, Well): + well = wells + x, y, z = well.get_location_wrt(deck, "c", "c", "cavity_bottom") + + if spread == "wide": + offsets = get_wide_single_resource_liquid_op_offsets( + resource=well, + num_channels=len(piercing_channels), + min_spacing=self._get_maximum_minimum_spacing_between_channels(piercing_channels), + ) + else: + offsets = get_tight_single_resource_liquid_op_offsets( + well, num_channels=len(piercing_channels) + ) + ys = [y + offset.y for offset in offsets] + else: + assert len(set(w.get_location_wrt(deck).x for w in wells)) == 1, ( + "Wells must be on the same column" + ) + absolute_center = wells[0].get_location_wrt(deck, "c", "c", "cavity_bottom") + x = absolute_center.x + ys = [well.get_location_wrt(deck, x="c", y="c").y for well in wells] + z = absolute_center.z + + await self._driver.left_x_arm.move_to(x) + + await self.position_channels_in_y_direction( + {channel: y for channel, y in zip(piercing_channels, ys)} + ) + + zs = [z + distance_from_bottom for _ in range(len(piercing_channels))] + if one_by_one: + for channel in piercing_channels: + await self.move_channel_z(channel, z + distance_from_bottom) + else: + await self.position_channels_in_z_direction( + {channel: z for channel, z in zip(piercing_channels, zs)} + ) + + await self.step_off_foil( + [wells] if isinstance(wells, Well) else wells, + back_channel=hold_down_channels[0], + front_channel=hold_down_channels[1], + move_inwards=move_inwards, + deck=deck, + ) + + async def step_off_foil( + self, + wells: Union[Well, List[Well]], + front_channel: int, + back_channel: int, + deck: Resource, + move_inwards: float = 2, + move_height: float = 15, + ): + """Hold down a plate by placing two channels on the edges of a plate that is sealed with foil + while moving up the channels that are still within the foil. This is useful when, for + example, aspirating from a plate that is sealed: without holding it down, the tips might get + stuck in the plate and move it up when retracting. Putting plates on the edge prevents this. + + Args: + wells: Wells in the plate to hold down. (x-coordinate of channels will be at center of wells). + Must be sorted from back to front. + front_channel: The channel to place on the front of the plate. + back_channel: The channel to place on the back of the plate. + deck: The deck resource, used to compute absolute positions of wells and plates. + move_inwards: mm to move inwards (backward on the front channel; frontward on the back). + move_height: mm to move upwards after piercing the foil. front_channel and back_channel will + hold the plate down. + """ + + if front_channel <= back_channel: + raise ValueError( + "front_channel should be in front of back_channel. Channels are 0-indexed from the back." + ) + + if isinstance(wells, Well): + wells = [wells] + + plates = set(well.parent for well in wells) + assert len(plates) == 1, "All wells must be in the same plate" + plate = plates.pop() + assert plate is not None + + z_location = plate.get_location_wrt(deck, z="top").z + + if plate.get_absolute_rotation().z % 360 == 0: + back_location = plate.get_location_wrt(deck, y="b") + front_location = plate.get_location_wrt(deck, y="f") + elif plate.get_absolute_rotation().z % 360 == 90: + back_location = plate.get_location_wrt(deck, x="r") + front_location = plate.get_location_wrt(deck, x="l") + elif plate.get_absolute_rotation().z % 360 == 180: + back_location = plate.get_location_wrt(deck, y="f") + front_location = plate.get_location_wrt(deck, y="b") + elif plate.get_absolute_rotation().z % 360 == 270: + back_location = plate.get_location_wrt(deck, x="l") + front_location = plate.get_location_wrt(deck, x="r") + else: + raise ValueError("Plate rotation must be a multiple of 90 degrees") + + try: + # Then move all channels in the y-space simultaneously. + await self.position_channels_in_y_direction( + { + front_channel: front_location.y + move_inwards, + back_channel: back_location.y - move_inwards, + } + ) + + await self.move_channel_z(front_channel, z_location) + await self.move_channel_z(back_channel, z_location) + finally: + # Move channels that are lower than the `front_channel` and `back_channel` to + # the just above the foil, in case the foil pops up. + zs = await self.get_channels_z_positions() + indices = [channel_idx for channel_idx, z in zs.items() if z < z_location] + idx = { + idx: z_location + move_height for idx in indices if idx not in (front_channel, back_channel) + } + await self.position_channels_in_z_direction(idx) + + # After that, all channels are clear to move up. + await self.move_all_channels_in_z_safety() diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/iswap_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/iswap_tests.py index 02b429eb646..a99c655be77 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/tests/iswap_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/iswap_tests.py @@ -1,7 +1,7 @@ import unittest from unittest.mock import AsyncMock, MagicMock -from pylabrobot.hamilton.liquid_handlers.star.iswap import iSWAP +from pylabrobot.hamilton.liquid_handlers.star.iswap import iSWAPBackend from pylabrobot.resources import Coordinate @@ -12,7 +12,7 @@ class TestiSWAPCommands(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.mock_driver = MagicMock() self.mock_driver.send_command = AsyncMock() - self.iswap = iSWAP(driver=self.mock_driver) + self.iswap = iSWAPBackend(driver=self.mock_driver) async def test_pick_up_at_location(self): """C0PPid0001xs03479xd0yj1142yd0zj1874zd0gr1th2800te2800gw4go1308gb1245gt20ga0gc0""" @@ -131,7 +131,7 @@ async def test_park(self): self.assertTrue(self.iswap.parked) async def test_park_custom_height(self): - await self.iswap.park(backend_params=iSWAP.ParkParams(minimum_traverse_height=200.0)) + await self.iswap.park(backend_params=iSWAPBackend.ParkParams(minimum_traverse_height=200.0)) self.mock_driver.send_command.assert_called_once_with( module="C0", @@ -151,7 +151,7 @@ async def test_open_gripper(self): async def test_close_gripper(self): await self.iswap.close_gripper( gripper_width=86.0, - backend_params=iSWAP.CloseGripperParams(grip_strength=5, plate_width_tolerance=0), + backend_params=iSWAPBackend.CloseGripperParams(grip_strength=5, plate_width_tolerance=0), ) self.mock_driver.send_command.assert_called_once_with( diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py index a8a3113523a..24fe863e4cb 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py @@ -1374,16 +1374,22 @@ def __init__( from pylabrobot.hamilton.liquid_handlers.star.head96_backend import STARHead96Backend from pylabrobot.hamilton.liquid_handlers.star.autoload import STARAutoload from pylabrobot.hamilton.liquid_handlers.star.cover import STARCover + from pylabrobot.hamilton.liquid_handlers.star.iswap import iSWAPBackend from pylabrobot.hamilton.liquid_handlers.star.wash_station import STARWashStation from pylabrobot.hamilton.liquid_handlers.star.x_arm import STARXArm self._new_pip = STARPIPBackend(self) self._new_head96 = STARHead96Backend(self) self._new_autoload = STARAutoload(driver=self) # type: ignore[arg-type] self._new_cover = STARCover(driver=self) # type: ignore[arg-type] + self._new_iswap = iSWAPBackend(driver=self) # type: ignore[arg-type] self._new_left_x_arm = STARXArm(driver=self, side="left") # type: ignore[arg-type] self._new_right_x_arm = STARXArm(driver=self, side="right") # type: ignore[arg-type] self._new_wash_station = STARWashStation(driver=self) # type: ignore[arg-type] + # Public aliases so STARPIPBackend (which sees self as its driver) can access these. + self.left_x_arm = self._new_left_x_arm + self.iswap = None # populated in setup() if iSWAP is installed + def _min_spacing_between(self, i: int, j: int) -> float: """Return the conservative minimum Y spacing required between channels *i* and *j*. @@ -1905,6 +1911,7 @@ async def pick_up_tips( minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, pickup_method: Optional[TipPickupMethod] = None, ): + """Deprecated: use ``star.pip.backend.pick_up_tips()``.""" from pylabrobot.capabilities.liquid_handling.standard import Pickup as NewPickup PickUpTipsParams = self._new_pip.PickUpTipsParams @@ -1927,6 +1934,7 @@ async def drop_tips( minimum_traverse_height_at_beginning_of_a_command: Optional[float] = None, z_position_at_end_of_a_command: Optional[float] = None, ): + """Deprecated: use ``star.pip.backend.drop_tips()``.""" from pylabrobot.capabilities.liquid_handling.standard import TipDrop as NewTipDrop DropTipsParams = self._new_pip.DropTipsParams @@ -2576,58 +2584,7 @@ async def aspirate( immersion_depth_direction: Optional[List[int]] = None, liquid_surfaces_no_lld: Optional[List[float]] = None, ): - """Aspirate liquid from the specified channels. - - For all parameters where `None` is the default value, STAR will use the default value, based on - the aspirations. For all list parameters, the length of the list must be equal to the number of - operations. - - Args: - ops: The aspiration operations to perform. - use_channels: The channels to use for the operations. - jet: whether to search for a jet liquid class. Only used on dispense. Default is False. - blow_out: whether to blow out air. Only used on dispense. Note that in the VENUS Liquid - Editor, this is called "empty". Default is False. - - lld_search_height: The height to start searching for the liquid level when using LLD. - clot_detection_height: Unknown, but probably the height to search for clots when doing LLD. - pull_out_distance_transport_air: The distance to pull out when aspirating air, if LLD is - disabled. - second_section_height: The height to start the second section of aspiration. - second_section_ratio: - minimum_height: The minimum height to move to, this is the end of aspiration. The channel will move linearly from the liquid surface to this height over the course of the aspiration. - immersion_depth: The z distance to move after detecting the liquid, can be into or away from the liquid surface. - surface_following_distance: The distance to follow the liquid surface. - transport_air_volume: The volume of air to aspirate after the liquid. - pre_wetting_volume: The volume of liquid to use for pre-wetting. - lld_mode: The liquid level detection mode to use. - gamma_lld_sensitivity: The sensitivity of the gamma LLD. - dp_lld_sensitivity: The sensitivity of the DP LLD. - aspirate_position_above_z_touch_off: If the LLD mode is Z_TOUCH_OFF, this is the height above the bottom of the well (presumably) to aspirate from. - detection_height_difference_for_dual_lld: Difference between the gamma and DP LLD heights if the LLD mode is DUAL. - swap_speed: Swap speed (on leaving liquid) [mm/s]. Must be between 3 and 1600. Default 100. - settling_time: The time to wait after mix. - mix_position_from_liquid_surface: The height to aspirate from for mix (LLD or absolute terms). - mix_surface_following_distance: The distance to follow the liquid surface for mix. - limit_curve_index: The index of the limit curve to use. - - use_2nd_section_aspiration: Whether to use the second section of aspiration. - retract_height_over_2nd_section_to_empty_tip: Unknown. - dispensation_speed_during_emptying_tip: Unknown. - dosing_drive_speed_during_2nd_section_search: Unknown. - z_drive_speed_during_2nd_section_search: Unknown. - cup_upper_edge: Unknown. - - minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to before starting an aspiration. - min_z_endpos: The minimum height to move to, this is the end of aspiration. - - hamilton_liquid_classes: Override the default liquid classes. See pylabrobot/liquid_handling/liquid_classes/hamilton/STARBackend.py - liquid_surface_no_lld: Liquid surface at function without LLD [mm]. Must be between 0 and 360. Defaults to well bottom + liquid height. Should use absolute z. - disable_volume_correction: Whether to disable liquid class volume correction for each operation. - - probe_liquid_height: PLR-specific parameter. If True, probe the liquid height using cLLD before aspirating to set the liquid_height of every operation instead of using the default 0. Liquid heights must not be set when using this function. - auto_surface_following_distance: automatically compute the surface following distance based on the container height<->volume functions. Requires liquid height to be specified or `probe_liquid_height=True`. - """ + """Deprecated: use ``star.pip.backend.aspirate()``.""" from pylabrobot.capabilities.liquid_handling.standard import Aspiration as NewAspiration AspirateParams = self._new_pip.AspirateParams @@ -2756,58 +2713,7 @@ async def dispense( mix_speed: Optional[List[float]] = None, dispensing_mode: Optional[List[int]] = None, ): - """Dispense liquid from the specified channels. - - For all parameters where `None` is the default value, STAR will use the default value, based on - the dispenses. For all list parameters, the length of the list must be equal to the number of - operations. - - Args: - ops: The dispense operations to perform. - use_channels: The channels to use for the dispense operations. - lld_search_height: The height to start searching for the liquid level when using LLD. - liquid_surface_no_lld: Liquid surface at function without LLD. - pull_out_distance_transport_air: The distance to pull out the tip for aspirating transport air if LLD is disabled. - second_section_height: The height of the second section. - second_section_ratio: The ratio of [the bottom of the container * 10000] / [the height top of the container]. - minimum_height: The minimum height at the end of the dispense. - immersion_depth: The distance above or below to liquid level to start dispensing. - surface_following_distance: The distance to follow the liquid surface. - cut_off_speed: Unknown. - stop_back_volume: Unknown. - transport_air_volume: The volume of air to dispense before dispensing the liquid. - lld_mode: The liquid level detection mode to use. - dispense_position_above_z_touch_off: The height to move after LLD mode found the Z touch off - position. - gamma_lld_sensitivity: The gamma LLD sensitivity. (1 = high, 4 = low) - dp_lld_sensitivity: The dp LLD sensitivity. (1 = high, 4 = low) - swap_speed: Swap speed (on leaving liquid) [mm/s]. Must be between 3 and 1600. Default 100. - settling_time: The settling time. - mix_position_from_liquid_surface: The height to move above the liquid surface for - mix. - mix_surface_following_distance: The distance to follow the liquid surface for mix. - limit_curve_index: The limit curve to use for the dispense. - minimum_traverse_height_at_beginning_of_a_command: The minimum height to move to before - starting a dispense. - min_z_endpos: The minimum height to move to after a dispense. - side_touch_off_distance: The distance to move to the side from the well for a dispense. - - hamilton_liquid_classes: Override the default liquid classes. See - pylabrobot/liquid_handling/liquid_classes/hamilton/STARBackend.py - disable_volume_correction: Whether to disable liquid class volume correction for each operation. - - jet: Whether to use jetting for each dispense. Defaults to `False` for all. Used for - determining the dispense mode. True for dispense mode 0 or 1. - blow_out: Whether to use "blow out" dispense mode for each dispense. Defaults to `False` for - all. This is labelled as "empty" in the VENUS liquid editor, but "blow out" in the firmware - documentation. True for dispense mode 1 or 3. - empty: Whether to use "empty" dispense mode for each dispense. Defaults to `False` for all. - Truly empty the tip, not available in the VENUS liquid editor, but is in the firmware - documentation. Dispense mode 4. - - probe_liquid_height: PLR-specific parameter. If True, probe the liquid height using cLLD before aspirating to set the liquid_height of every operation instead of using the default 0. Liquid heights must not be set when using this function. - auto_surface_following_distance: automatically compute the surface following distance based on the container height<->volume functions. Requires liquid height to be specified or `probe_liquid_height=True`. - """ + """Deprecated: use ``star.pip.backend.dispense()``.""" from pylabrobot.capabilities.liquid_handling.standard import Dispense as NewDispense DispenseParams = self._new_pip.DispenseParams @@ -4023,8 +3929,8 @@ async def prepare_for_manual_channel_operation(self, channel: int): await self.position_max_free_y_for_n(pipetting_channel_index=channel) async def move_channel_x(self, channel: int, x: float): - """Move a channel in the x direction.""" - await self.position_left_x_arm_(round(x * 10)) + """Deprecated: use ``star._driver.left_x_arm.move_to()``.""" + await self._new_left_x_arm.move_to(x) @need_iswap_parked async def move_channel_y(self, channel: int, y: float): @@ -4062,10 +3968,8 @@ async def move_channel_y(self, channel: int, y: float): ) async def move_channel_z(self, channel: int, z: float): - """Move a channel in the z direction.""" - await self.position_single_pipetting_channel_in_z_direction( - pipetting_channel_index=channel + 1, z_position=round(z * 10) - ) + """Deprecated: use ``star.pip.backend.move_channel_z()``.""" + await self._new_pip.move_channel_z(channel, z) async def move_channel_x_relative(self, channel: int, distance: float): """Move a channel in the x direction by a relative amount.""" @@ -4852,7 +4756,7 @@ async def request_right_x_arm_last_collision_type(self) -> bool: # -------------- 3.5.1 Initialization -------------- async def initialize_pip(self): - """Wrapper around initialize_pipetting_channels firmware command.""" + """Deprecated: use ``star.pip.backend.initialize_pip()``.""" dy = (4050 - 2175) // (self.num_channels - 1) y_positions = [4050 - i * dy for i in range(self.num_channels)] @@ -4880,25 +4784,7 @@ async def initialize_pipetting_channels( tip_type: int = 16, discarding_method: int = 1, ): - """Initialize pipetting channels - - Initialize pipetting channels (discard tips) - - Args: - x_positions: X-Position [0.1mm] (discard position). Must be between 0 and 25000. Default 0. - y_positions: y-Position [0.1mm] (discard position). Must be between 0 and 6500. Default 0. - begin_of_tip_deposit_process: Begin of tip deposit process (Z-discard range) [0.1mm]. Must be - between 0 and 3600. Default 0. - end_of_tip_deposit_process: End of tip deposit process (Z-discard range) [0.1mm]. Must be - between 0 and 3600. Default 0. - z-position_at_end_of_a_command: Z-Position at end of a command [0.1mm]. Must be between 0 and - 3600. Default 3600. - tip_pattern: Tip pattern ( channels involved). Default True. - tip_type: Tip type (recommended is index of longest tip see command 'TT') [0.1mm]. Must be - between 0 and 99. Default 16. - discarding_method: discarding method. 0 = place & shift (tp/ tz = tip cone end height), 1 = - drop (no shift) (tp/ tz = stop disk height). Must be between 0 and 1. Default 1. - """ + """Deprecated: use ``star.pip.backend.initialize_pipetting_channels()``.""" assert all(0 <= xp <= 25000 for xp in x_positions), "x_positions must be between 0 and 25000" assert all(0 <= yp <= 6500 for yp in y_positions), "y_positions must be between 0 and 6500" @@ -6044,7 +5930,7 @@ async def search_for_teach_in_signal_using_pipetting_channel_n_in_x_direction( ) async def spread_pip_channels(self): - """Spread PIP channels""" + """Deprecated: use ``star.pip.backend.spread_pip_channels()``.""" return await self.send_command(module="C0", command="JE") @@ -6057,18 +5943,7 @@ async def move_all_pipetting_channels_to_defined_position( minimum_traverse_height_at_beginning_of_command: int = 3600, z_endpos: int = 0, ): - """Move all pipetting channels to defined position - - Args: - tip_pattern: Tip pattern (channels involved). Default True. - x_positions: x positions [0.1mm]. Must be between 0 and 25000. Default 0. - y_positions: y positions [0.1mm]. Must be between 0 and 6500. Default 0. - minimum_traverse_height_at_beginning_of_command: Minimum traverse height at beginning of a - command 0.1mm] (refers to all channels independent of tip pattern parameter 'tm'). Must be - between 0 and 3600. Default 3600. - z_endpos: Z-Position at end of a command [0.1 mm] (refers to all channels independent of tip - pattern parameter 'tm'). Must be between 0 and 3600. Default 0. - """ + """Deprecated: use ``star.pip.backend.move_all_pipetting_channels_to_defined_position()``.""" assert 0 <= x_positions <= 25000, "x_positions must be between 0 and 25000" assert 0 <= y_positions <= 6500, "y_positions must be between 0 and 6500" @@ -6091,11 +5966,7 @@ async def move_all_pipetting_channels_to_defined_position( @need_iswap_parked async def position_max_free_y_for_n(self, pipetting_channel_index: int): - """Position all pipetting channels so that there is maximum free Y range for channel n - - Args: - pipetting_channel_index: Index of pipetting channel. Must be between 0 and self.num_channels. - """ + """Deprecated: use ``star.pip.backend.position_max_free_y_for_n()``.""" assert 0 <= pipetting_channel_index < self.num_channels, ( "pipetting_channel_index must be between 1 and self.num_channels" @@ -6110,7 +5981,7 @@ async def position_max_free_y_for_n(self, pipetting_channel_index: int): ) async def move_all_channels_in_z_safety(self): - """Move all pipetting channels in Z-safety position""" + """Deprecated: use ``star.pip.backend.move_all_channels_in_z_safety()``.""" return await self.send_command(module="C0", command="ZA") @@ -8167,9 +8038,8 @@ async def drain_dual_chamber_system(self, pump_station: int = 1): # -------------- 3.17.1 Pre & Initialization commands -------------- async def initialize_iswap(self): - """Initialize iSWAP (for standalone configuration only)""" - - return await self.send_command(module="C0", command="FI") + """Deprecated: use ``star.iswap.initialize()``.""" + return await self._new_iswap.initialize() async def position_components_for_free_iswap_y_range(self): """Position all components so that there is maximum free Y range for iSWAP""" @@ -8177,36 +8047,17 @@ async def position_components_for_free_iswap_y_range(self): return await self.send_command(module="C0", command="FY") async def move_iswap_x_relative(self, step_size: float, allow_splitting: bool = False): - """ - Args: - step_size: X Step size [1mm] Between -99.9 and 99.9 if allow_splitting is False. - allow_splitting: Allow splitting of the movement into multiple steps. Default False. - """ - - direction = 0 if step_size >= 0 else 1 - max_step_size = 99.9 - if abs(step_size) > max_step_size: - if not allow_splitting: - raise ValueError("step_size must be less than 99.9") - await self.move_iswap_x_relative( - step_size=max_step_size if step_size > 0 else -max_step_size, allow_splitting=True - ) - remaining_steps = step_size - max_step_size if step_size > 0 else step_size + max_step_size - return await self.move_iswap_x_relative(remaining_steps, allow_splitting) - - return await self.send_command( - module="C0", command="GX", gx=str(round(abs(step_size) * 10)).zfill(3), xd=direction - ) + """Deprecated: use ``star.iswap.move_x_relative()``.""" + return await self._new_iswap.move_x_relative(step_size=step_size, allow_splitting=allow_splitting) async def move_iswap_y_relative(self, step_size: float, allow_splitting: bool = False): - """ - Args: - step_size: Y Step size [1mm] Between -99.9 and 99.9 if allow_splitting is False. - allow_splitting: Allow splitting of the movement into multiple steps. Default False. - """ + """Deprecated: use ``star.iswap.move_y_relative()``. - # check if iswap will hit the first (backmost) channel - # we only need to check for positive step sizes because the iswap is always behind the first channel + Note: this legacy method includes a collision check against channel 0 that is not + present in the new API. Callers relying on that safety check should perform it + explicitly before calling ``move_y_relative``. + """ + # Legacy collision check — kept here because it uses legacy-only helpers. if step_size < 0: y_pos_channel_0 = await self.request_y_pos_channel_n(0) current_y_pos_iswap = await self.iswap_rotation_drive_request_y() @@ -8215,70 +8066,27 @@ async def move_iswap_y_relative(self, step_size: float, allow_splitting: bool = f"iSWAP will hit the first (backmost) channel. Current iSWAP Y position: {current_y_pos_iswap} mm, " f"first channel Y position: {y_pos_channel_0} mm, requested step size: {step_size} mm" ) - - direction = 0 if step_size >= 0 else 1 - max_step_size = 99.9 - if abs(step_size) > max_step_size: - if not allow_splitting: - raise ValueError("step_size must be less than 99.9") - await self.move_iswap_y_relative( - step_size=max_step_size if step_size > 0 else -max_step_size, allow_splitting=True - ) - remaining_steps = step_size - max_step_size if step_size > 0 else step_size + max_step_size - return await self.move_iswap_y_relative(remaining_steps, allow_splitting) - - return await self.send_command( - module="C0", command="GY", gy=str(round(abs(step_size) * 10)).zfill(3), yd=direction - ) + return await self._new_iswap.move_y_relative(step_size=step_size, allow_splitting=allow_splitting) async def move_iswap_z_relative(self, step_size: float, allow_splitting: bool = False): - """ - Args: - step_size: Z Step size [1mm] Between -99.9 and 99.9 if allow_splitting is False. - allow_splitting: Allow splitting of the movement into multiple steps. Default False. - """ - - direction = 0 if step_size >= 0 else 1 - max_step_size = 99.9 - if abs(step_size) > max_step_size: - if not allow_splitting: - raise ValueError("step_size must be less than 99.9") - await self.move_iswap_z_relative( - step_size=max_step_size if step_size > 0 else -max_step_size, allow_splitting=True - ) - remaining_steps = step_size - max_step_size if step_size > 0 else step_size + max_step_size - return await self.move_iswap_z_relative(remaining_steps, allow_splitting) - - return await self.send_command( - module="C0", command="GZ", gz=str(round(abs(step_size) * 10)).zfill(3), zd=direction - ) + """Deprecated: use ``star.iswap.move_z_relative()``.""" + return await self._new_iswap.move_z_relative(step_size=step_size, allow_splitting=allow_splitting) async def move_iswap_x(self, x_position: float): - """Move iSWAP X to absolute position""" - loc = await self.request_iswap_position() - await self.move_iswap_x_relative( - step_size=x_position - loc.x, - allow_splitting=True, - ) + """Deprecated: use ``star.iswap.move_x()``.""" + return await self._new_iswap.move_x(x_position) async def move_iswap_y(self, y_position: float): - """Move iSWAP Y to absolute position""" - loc = await self.request_iswap_position() - await self.move_iswap_y_relative( - step_size=y_position - loc.y, - allow_splitting=True, - ) + """Deprecated: use ``star.iswap.move_y()``.""" + return await self._new_iswap.move_y(y_position) async def move_iswap_z(self, z_position: float): - """Move iSWAP Z to absolute position""" - loc = await self.request_iswap_position() - await self.move_iswap_z_relative( - step_size=z_position - loc.z, - allow_splitting=True, - ) + """Deprecated: use ``star.iswap.move_z()``.""" + return await self._new_iswap.move_z(z_position) async def open_not_initialized_gripper(self): - return await self.send_command(module="C0", command="GI") + """Deprecated: use ``star.iswap.open_not_initialized_gripper()``.""" + return await self._new_iswap.open_not_initialized_gripper() async def iswap_open_gripper(self, open_position: Optional[float] = None): """Open gripper @@ -8538,84 +8346,22 @@ async def iswap_put_plate( return command_output async def request_iswap_rotation_drive_position_increments(self) -> int: - """Query the iSWAP rotation drive position (units: increments) from the firmware.""" - response = await self.send_command(module="R0", command="RW", fmt="rw######") - return cast(int, response["rw"]) + """Deprecated: use ``star.iswap.request_rotation_drive_position_increments()``.""" + return await self._new_iswap.request_rotation_drive_position_increments() async def request_iswap_rotation_drive_orientation(self) -> "RotationDriveOrientation": - """ - Request the iSWAP rotation drive orientation. - This is the orientation of the iSWAP rotation drive (relative to the machine). - - Uses empirically determined increment values: - FRONT: -25 ± 50 - RIGHT: +29068 ± 50 - LEFT: -29116 ± 50 - - Returns: - RotationDriveOrientation: The interpreted rotation orientation (LEFT, FRONT, RIGHT). - """ - # Map motor increments to rotation orientations (constant lookup table). - rotation_orientation_to_motor_increment_dict = { - STARBackend.RotationDriveOrientation.FRONT: range(-75, 26), - STARBackend.RotationDriveOrientation.RIGHT: range(29018, 29119), - STARBackend.RotationDriveOrientation.LEFT: range(-29166, -29065), - STARBackend.RotationDriveOrientation.PARKED_RIGHT: range(29450, 29550), - # TODO: add range for STAR(let)s with "PARKED_LEFT" setting - } - - motor_position_increments = await self.request_iswap_rotation_drive_position_increments() - - for orientation, increment_range in rotation_orientation_to_motor_increment_dict.items(): - if motor_position_increments in increment_range: - return orientation - - raise ValueError( - f"Unknown rotation orientation: {motor_position_increments}. " - f"Expected one of {list(rotation_orientation_to_motor_increment_dict.values())}." - ) + """Deprecated: use ``star.iswap.request_rotation_drive_orientation()``.""" + new_orient = await self._new_iswap.request_rotation_drive_orientation() + return STARBackend.RotationDriveOrientation(new_orient.value) async def request_iswap_wrist_drive_position_increments(self) -> int: - """Query the iSWAP wrist drive position (units: increments) from the firmware.""" - response = await self.send_command(module="R0", command="RT", fmt="rt######") - return cast(int, response["rt"]) + """Deprecated: use ``star.iswap.request_wrist_drive_position_increments()``.""" + return await self._new_iswap.request_wrist_drive_position_increments() async def request_iswap_wrist_drive_orientation(self) -> "WristDriveOrientation": - """ - Request the iSWAP wrist drive orientation. - This is the orientation of the iSWAP wrist drive (always in relation to the iSWAP arm/rotation drive). - - e.g.: - 1) iSWAP RotationDriveOrientation.FRONT (i.e. pointing to the front of the machine) + iSWAP WristDriveOrientation.STRAIGHT (i.e. wrist is also pointing to the front) - - 2) iSWAP RotationDriveOrientation.LEFT (i.e. pointing to the left of the machine) + iSWAP WristDriveOrientation.STRAIGHT (i.e. wrist is also pointing to the left) - - 3) iSWAP RotationDriveOrientation.FRONT (i.e. pointing to the front of the machine) + iSWAP WristDriveOrientation.RIGHT (i.e. wrist is pointing to the left !) - - The relative wrist orientation is reported as a motor position increment by the STAR firmware. This value is mapped to a `WristDriveOrientation` enum member. - - Returns: - WristDriveOrientation: The interpreted wrist orientation (e.g., RIGHT, STRAIGHT, LEFT, REVERSE). - """ - - # Map motor increments to wrist orientations (constant lookup table). - wrist_orientation_to_motor_increment_dict = { - STARBackend.WristDriveOrientation.RIGHT: range(-26_627, -26_527), - STARBackend.WristDriveOrientation.STRAIGHT: range(-8_804, -8_704), - STARBackend.WristDriveOrientation.LEFT: range(9_051, 9_151), - STARBackend.WristDriveOrientation.REVERSE: range(26_802, 26_902), - } - - motor_position_increments = await self.request_iswap_wrist_drive_position_increments() - - for orientation, increment_range in wrist_orientation_to_motor_increment_dict.items(): - if motor_position_increments in increment_range: - return orientation - - raise ValueError( - f"Unknown wrist orientation: {motor_position_increments}. " - f"Expected one of {list(wrist_orientation_to_motor_increment_dict)}." - ) + """Deprecated: use ``star.iswap.request_wrist_drive_orientation()``.""" + new_orient = await self._new_iswap.request_wrist_drive_orientation() + return STARBackend.WristDriveOrientation(new_orient.value) async def iswap_rotate( self, @@ -8628,59 +8374,29 @@ async def iswap_rotate( wrist_acceleration: int = 145, wrist_protection: Literal[0, 1, 2, 3, 4, 5, 6, 7] = 5, ): - """ - Rotate the iswap to a predefined position. - Velocity units are "incr/sec" - Acceleration units are "1_000 incr/sec**2" - For a list of the possible positions see the pylabrobot documentation on the R0 module. - """ - assert 20 <= gripper_velocity <= 75_000 - assert 5 <= gripper_acceleration <= 200 - assert 20 <= wrist_velocity <= 65_000 - assert 20 <= wrist_acceleration <= 200 - - position = 0 - - if rotation_drive == STARBackend.RotationDriveOrientation.LEFT: - position += 10 - elif rotation_drive == STARBackend.RotationDriveOrientation.FRONT: - position += 20 - elif rotation_drive == STARBackend.RotationDriveOrientation.RIGHT: - position += 30 - else: - raise ValueError(f"Invalid rotation drive orientation: {rotation_drive}") - - if grip_direction == GripDirection.FRONT: - position += 1 - elif grip_direction == GripDirection.RIGHT: - position += 2 - elif grip_direction == GripDirection.BACK: - position += 3 - elif grip_direction == GripDirection.LEFT: - position += 4 - else: - raise ValueError("Invalid grip direction") - - return await self.send_command( - module="R0", - command="PD", - pd=position, - wv=f"{gripper_velocity:05}", - wr=f"{gripper_acceleration:03}", - ww=gripper_protection, - tv=f"{wrist_velocity:05}", - tr=f"{wrist_acceleration:03}", - tw=wrist_protection, + """Deprecated: use ``star.iswap.rotate()``.""" + return await self._new_iswap.rotate( + rotation_drive=rotation_drive, + grip_direction=grip_direction, + gripper_velocity=gripper_velocity, + gripper_acceleration=gripper_acceleration, + gripper_protection=gripper_protection, + wrist_velocity=wrist_velocity, + wrist_acceleration=wrist_acceleration, + wrist_protection=wrist_protection, ) async def iswap_dangerous_release_break(self): - return await self.send_command(module="R0", command="BA") + """Deprecated: use ``star.iswap.dangerous_release_brake()``.""" + return await self._new_iswap.dangerous_release_brake() async def iswap_reengage_break(self): - return await self.send_command(module="R0", command="BO") + """Deprecated: use ``star.iswap.reengage_brake()``.""" + return await self._new_iswap.reengage_brake() async def iswap_initialize_z_axis(self): - return await self.send_command(module="R0", command="ZI") + """Deprecated: use ``star.iswap.initialize_z_axis()``.""" + return await self._new_iswap.initialize_z_axis() async def move_plate_to_position( self, @@ -8756,24 +8472,10 @@ async def collapse_gripper_arm( minimum_traverse_height_at_beginning_of_a_command: int = 3600, iswap_fold_up_sequence_at_the_end_of_process: bool = False, ): - """Collapse gripper arm - - Args: - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of a - command 0.1mm]. Must be between 0 and 3600. - Default 3600. - iswap_fold_up_sequence_at_the_end_of_process: fold up sequence at the end of process. Default False. - """ - - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - - return await self.send_command( - module="C0", - command="PN", - th=minimum_traverse_height_at_beginning_of_a_command, - gc=iswap_fold_up_sequence_at_the_end_of_process, + """Deprecated: use ``star.iswap.collapse_gripper_arm()``.""" + return await self._new_iswap.collapse_gripper_arm( + minimum_traverse_height=minimum_traverse_height_at_beginning_of_a_command / 10, + fold_up_at_end=iswap_fold_up_sequence_at_the_end_of_process, ) # -------------- 3.17.3 Hotel handling commands -------------- @@ -8802,61 +8504,21 @@ async def prepare_iswap_teaching( acceleration_index_high_acc: int = 4, acceleration_index_low_acc: int = 1, ): - """Prepare iSWAP teaching - - Prepare for teaching with iSWAP - - Args: - x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. - x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. - y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. - z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - location: location. 0 = Stack 1 = Hotel. Must be between 0 and 1. Default 0. - hotel_depth: Hotel depth [0.1mm]. Must be between 0 and 3000. Default 1300. - minimum_traverse_height_at_beginning_of_a_command: Minimum traverse height at beginning of - a command 0.1mm]. Must be between 0 and 3600. Default 3600. - collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. - Default 1. - acceleration_index_high_acc: acceleration index high acc. Must be between 0 and 4. Default 4. - acceleration_index_low_acc: acceleration index high acc. Must be between 0 and 4. Default 1. - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" - assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" - assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" - assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" - assert 0 <= location <= 1, "location must be between 0 and 1" - assert 0 <= hotel_depth <= 3000, "hotel_depth must be between 0 and 3000" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600, ( - "minimum_traverse_height_at_beginning_of_a_command must be between 0 and 3600" - ) - assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" - assert 0 <= acceleration_index_high_acc <= 4, ( - "acceleration_index_high_acc must be between 0 and 4" - ) - assert 0 <= acceleration_index_low_acc <= 4, ( - "acceleration_index_low_acc must be between 0 and 4" - ) - - return await self.send_command( - module="C0", - command="PT", - xs=f"{x_position:05}", - xd=x_direction, - yj=f"{y_position:04}", - yd=y_direction, - zj=f"{z_position:04}", - zd=z_direction, - hh=location, - hd=f"{hotel_depth:04}", - gr=grip_direction, - th=f"{minimum_traverse_height_at_beginning_of_a_command:04}", - ga=collision_control_level, - xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", + """Deprecated: use ``star.iswap.prepare_teaching()``.""" + return await self._new_iswap.prepare_teaching( + x_position=x_position, + x_direction=x_direction, + y_position=y_position, + y_direction=y_direction, + z_position=z_position, + z_direction=z_direction, + location=location, + hotel_depth=hotel_depth, + grip_direction=grip_direction, + minimum_traverse_height=minimum_traverse_height_at_beginning_of_a_command, + collision_control_level=collision_control_level, + acceleration_index_high_acc=acceleration_index_high_acc, + acceleration_index_low_acc=acceleration_index_low_acc, ) async def get_logic_iswap_position( @@ -8872,111 +8534,45 @@ async def get_logic_iswap_position( grip_direction: int = 1, collision_control_level: int = 1, ): - """Get logic iSWAP position - - Args: - x_position: Plate center in X direction [0.1mm]. Must be between 0 and 30000. Default 0. - x_direction: X-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - y_position: Plate center in Y direction [0.1mm]. Must be between 0 and 6500. Default 0. - y_direction: Y-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - z_position: Plate gripping height in Z direction. Must be between 0 and 3600. Default 0. - z_direction: Z-direction. 0 = positive 1 = negative. Must be between 0 and 1. Default 0. - location: location. 0 = Stack 1 = Hotel. Must be between 0 and 1. Default 0. - hotel_depth: Hotel depth [0.1mm]. Must be between 0 and 3000. Default 1300. - grip_direction: Grip direction. 1 = negative Y, 2 = positive X, 3 = positive Y, - 4 = negative X. Must be between 1 and 4. Default 1. - collision_control_level: collision control level 1 = high 0 = low. Must be between 0 and 1. - Default 1. - """ - - assert 0 <= x_position <= 30000, "x_position must be between 0 and 30000" - assert 0 <= x_direction <= 1, "x_direction must be between 0 and 1" - assert 0 <= y_position <= 6500, "y_position must be between 0 and 6500" - assert 0 <= y_direction <= 1, "y_direction must be between 0 and 1" - assert 0 <= z_position <= 3600, "z_position must be between 0 and 3600" - assert 0 <= z_direction <= 1, "z_direction must be between 0 and 1" - assert 0 <= location <= 1, "location must be between 0 and 1" - assert 0 <= hotel_depth <= 3000, "hotel_depth must be between 0 and 3000" - assert 1 <= grip_direction <= 4, "grip_direction must be between 1 and 4" - assert 0 <= collision_control_level <= 1, "collision_control_level must be between 0 and 1" - - return await self.send_command( - module="C0", - command="PC", - xs=x_position, - xd=x_direction, - yj=y_position, - yd=y_direction, - zj=z_position, - zd=z_direction, - hh=location, - hd=hotel_depth, - gr=grip_direction, - ga=collision_control_level, + """Deprecated: use ``star.iswap.get_logic_position()``.""" + return await self._new_iswap.get_logic_position( + x_position=x_position, + x_direction=x_direction, + y_position=y_position, + y_direction=y_direction, + z_position=z_position, + z_direction=z_direction, + location=location, + hotel_depth=hotel_depth, + grip_direction=grip_direction, + collision_control_level=collision_control_level, ) # -------------- 3.17.6 iSWAP query -------------- async def request_iswap_in_parking_position(self): - """Request iSWAP in parking position - - Returns: - 0 = gripper is not in parking position - 1 = gripper is in parking position - """ - - return await self.send_command(module="C0", command="RG", fmt="rg#") + """Deprecated: use ``star.iswap.request_in_parking_position()``.""" + return await self._new_iswap.request_in_parking_position() async def request_plate_in_iswap(self) -> bool: - """Request plate in iSWAP - - Returns: - True if holding a plate, False otherwise. - """ - - resp = await self.send_command(module="C0", command="QP", fmt="ph#") - return resp is not None and resp["ph"] == 1 + """Deprecated: use ``star.iswap.is_gripper_closed()``.""" + return await self._new_iswap.is_gripper_closed() async def request_iswap_position(self) -> Coordinate: - """Request iSWAP position ( grip center ) - - Returns: - xs: Hotel center in X direction [1mm] - xd: X direction 0 = positive 1 = negative - yj: Gripper center in Y direction [1mm] - yd: Y direction 0 = positive 1 = negative - zj: Gripper Z height (gripping height) [1mm] - zd: Z direction 0 = positive 1 = negative - """ - - resp = await self.send_command(module="C0", command="QG", fmt="xs#####xd#yj####yd#zj####zd#") - return Coordinate( - x=(resp["xs"] / 10) * (1 if resp["xd"] == 0 else -1), - y=(resp["yj"] / 10) * (1 if resp["yd"] == 0 else -1), - z=(resp["zj"] / 10) * (1 if resp["zd"] == 0 else -1), - ) + """Deprecated: use ``star.iswap.get_gripper_location()``.""" + return (await self._new_iswap.get_gripper_location()).location async def iswap_rotation_drive_request_y(self) -> float: - """Request iSWAP rotation drive Y position (center) in mm. This is equivalent to the y location of the iSWAP module.""" - if not self.extended_conf.left_x_drive.iswap_installed: - raise RuntimeError("iSWAP is not installed") - resp = await self.send_command(module="R0", command="RY", fmt="ry##### (n)") - iswap_y_pos = resp["ry"][1] # 0 = FW counter, 1 = HW counter - return round(STARBackend.y_drive_increment_to_mm(iswap_y_pos), 1) + """Deprecated: use ``star.iswap.rotation_drive_request_y()``.""" + return await self._new_iswap.rotation_drive_request_y() async def request_iswap_initialization_status(self) -> bool: - """Request iSWAP initialization status - - Returns: - True if iSWAP is fully initialized - """ - - resp = await self.send_command(module="R0", command="QW", fmt="qw#") - return cast(int, resp["qw"]) == 1 + """Deprecated: use ``star.iswap.request_initialization_status()``.""" + return await self._new_iswap.request_initialization_status() async def request_iswap_version(self) -> str: - """Firmware command for getting iswap version""" - return cast(str, (await self.send_command("R0", "RF", fmt="rf" + "&" * 15))["rf"]) + """Deprecated: use ``star.iswap.version`` (property, available after setup).""" + return await self._new_iswap._request_version() # -------------- 3.18 Cover and port control -------------- @@ -10169,19 +9765,8 @@ class RotationDriveOrientation(enum.Enum): PARKED_RIGHT = None async def rotate_iswap_rotation_drive(self, orientation: RotationDriveOrientation): - if orientation in { - STARBackend.RotationDriveOrientation.RIGHT, - STARBackend.RotationDriveOrientation.FRONT, - STARBackend.RotationDriveOrientation.LEFT, - }: - return await self.send_command( - module="R0", - command="WP", - auto_id=False, - wp=orientation.value, - ) - else: - raise ValueError(f"Invalid rotation drive orientation: {orientation}") + """Deprecated: use ``star.iswap.rotate_rotation_drive()``.""" + return await self._new_iswap.rotate_rotation_drive(orientation) class WristDriveOrientation(enum.Enum): RIGHT = 1 @@ -10190,12 +9775,8 @@ class WristDriveOrientation(enum.Enum): REVERSE = 4 async def rotate_iswap_wrist(self, orientation: WristDriveOrientation): - return await self.send_command( - module="R0", - command="TP", - auto_id=False, - tp=orientation.value, - ) + """Deprecated: use ``star.iswap.rotate_wrist()``.""" + return await self._new_iswap.rotate_wrist(orientation) @staticmethod def channel_id(channel_idx: int) -> str: @@ -10204,7 +9785,7 @@ def channel_id(channel_idx: int) -> str: return "P" + channel_ids[channel_idx] async def get_channels_y_positions(self) -> Dict[int, float]: - """Get the Y position of all channels in mm""" + """Deprecated: use ``star.pip.backend.get_channels_y_positions()``.""" resp = await self.send_command( module="C0", command="RY", @@ -10237,17 +9818,7 @@ async def get_channels_y_positions(self) -> Dict[int, float]: @need_iswap_parked async def position_channels_in_y_direction(self, ys: Dict[int, float], make_space=True): - """position all channels simultaneously in the Y direction. - - Args: - ys: A dictionary mapping channel index to the desired Y position in mm. The channel index is - 0-indexed from the back. - make_space: If True, the channels will be moved to ensure they respect each channel pair's - minimum Y spacing and are in descending order, after the channels in `ys` have been put - at the desired locations. Note that an error may still be raised, if there is insufficient - space to move the channels or if the requested locations are not valid. Set this to False - if you want to avoid inadvertently moving other channels. - """ + """Deprecated: use ``star.pip.backend.position_channels_in_y_direction()``.""" # check that the locations of channels after the move will respect pairwise minimum # spacing and be in descending order @@ -10313,7 +9884,7 @@ async def position_channels_in_y_direction(self, ys: Dict[int, float], make_spac ) async def get_channels_z_positions(self) -> Dict[int, float]: - """Get the Y position of all channels in mm""" + """Deprecated: use ``star.pip.backend.get_channels_z_positions()``.""" resp = await self.send_command( module="C0", command="RZ", @@ -10322,6 +9893,7 @@ async def get_channels_z_positions(self) -> Dict[int, float]: return {channel_idx: round(y / 10, 2) for channel_idx, y in enumerate(resp["rz"])} async def position_channels_in_z_direction(self, zs: Dict[int, float]): + """Deprecated: use ``star.pip.backend.position_channels_in_z_direction()``.""" channel_locations = await self.get_channels_z_positions() for channel_idx, z in zs.items(): @@ -10341,74 +9913,16 @@ async def pierce_foil( one_by_one: bool = False, distance_from_bottom: float = 20.0, ): - """Pierce the foil of the media source plate at the specified column. Throw away the tips - after piercing because there will be a bit of foil stuck to the tips. Use this method - before aspirating from a foil-sealed plate to make sure the tips are clean and the - aspirations are accurate. - - Args: - wells: Well or wells in the plate to pierce the foil. If multiple wells, they must be on one - column. - piercing_channels: The channels to use for piercing the foil. - hold_down_channels: The channels to use for holding down the plate when moving up the - piercing channels. - spread: The spread of the piercing channels in the well. - one_by_one: If True, the channels will pierce the foil one by one. If False, all channels - will pierce the foil simultaneously. - """ - - x: float - ys: List[float] - z: float - - # if only one well is give, but in a list, convert to Well so we fall into single-well logic. - if isinstance(wells, list) and len(wells) == 1: - wells = wells[0] - - if isinstance(wells, Well): - well = wells - x, y, z = well.get_location_wrt(self.deck, "c", "c", "cavity_bottom") - - if spread == "wide": - offsets = get_wide_single_resource_liquid_op_offsets( - resource=well, - num_channels=len(piercing_channels), - min_spacing=self._get_maximum_minimum_spacing_between_channels(piercing_channels), - ) - else: - offsets = get_tight_single_resource_liquid_op_offsets( - well, num_channels=len(piercing_channels) - ) - ys = [y + offset.y for offset in offsets] - else: - assert len(set(w.get_location_wrt(self.deck).x for w in wells)) == 1, ( - "Wells must be on the same column" - ) - absolute_center = wells[0].get_location_wrt(self.deck, "c", "c", "cavity_bottom") - x = absolute_center.x - ys = [well.get_location_wrt(self.deck, x="c", y="c").y for well in wells] - z = absolute_center.z - - await self.move_channel_x(0, x=x) - - await self.position_channels_in_y_direction( - {channel: y for channel, y in zip(piercing_channels, ys)} - ) - - zs = [z + distance_from_bottom for _ in range(len(piercing_channels))] - if one_by_one: - for channel in piercing_channels: - await self.move_channel_z(channel, z) - else: - await self.position_channels_in_z_direction( - {channel: z for channel, z in zip(piercing_channels, zs)} - ) - - await self.step_off_foil( - [wells] if isinstance(wells, Well) else wells, - back_channel=hold_down_channels[0], - front_channel=hold_down_channels[1], + """Deprecated: use ``star.pip.backend.pierce_foil()``.""" + await self._new_pip.pierce_foil( + wells=wells, + piercing_channels=piercing_channels, + hold_down_channels=hold_down_channels, move_inwards=move_inwards, + deck=self.deck, + spread=spread, + one_by_one=one_by_one, + distance_from_bottom=distance_from_bottom, ) async def step_off_foil( @@ -10419,87 +9933,15 @@ async def step_off_foil( move_inwards: float = 2, move_height: float = 15, ): - """ - Hold down a plate by placing two channels on the edges of a plate that is sealed with foil - while moving up the channels that are still within the foil. This is useful when, for - example, aspirating from a plate that is sealed: without holding it down, the tips might get - stuck in the plate and move it up when retracting. Putting plates on the edge prevents this. - - When aspirating or dispensing in the foil, be sure to set the `min_z_endpos` parameter in - `lh.aspirate` or `lh.dispense` to a value in the foil. You might want to use something like - - .. code-block:: python - - well = plate.get_well("A3") - await lh.aspirate( - [well]*4, vols=[100]*4, use_channels=[7,8,9,10], - min_z_endpos=well.get_location_wrt(self.deck, z="cavity_bottom").z, - surface_following_distance=0, - pull_out_distance_transport_air=[0] * 4) - await step_off_foil(lh.backend, [well], front_channel=11, back_channel=6, move_inwards=3) - - Args: - wells: Wells in the plate to hold down. (x-coordinate of channels will be at center of wells). - Must be sorted from back to front. - front_channel: The channel to place on the front of the plate. - back_channel: The channel to place on the back of the plate. - move_inwards: mm to move inwards (backward on the front channel; frontward on the back). - move_height: mm to move upwards after piercing the foil. front_channel and back_channel will hold the plate down. - """ - - if front_channel <= back_channel: - raise ValueError( - "front_channel should be in front of back_channel. Channels are 0-indexed from the back." - ) - - if isinstance(wells, Well): - wells = [wells] - - plates = set(well.parent for well in wells) - assert len(plates) == 1, "All wells must be in the same plate" - plate = plates.pop() - assert plate is not None - - z_location = plate.get_location_wrt(self.deck, z="top").z - - if plate.get_absolute_rotation().z % 360 == 0: - back_location = plate.get_location_wrt(self.deck, y="b") - front_location = plate.get_location_wrt(self.deck, y="f") - elif plate.get_absolute_rotation().z % 360 == 90: - back_location = plate.get_location_wrt(self.deck, x="r") - front_location = plate.get_location_wrt(self.deck, x="l") - elif plate.get_absolute_rotation().z % 360 == 180: - back_location = plate.get_location_wrt(self.deck, y="f") - front_location = plate.get_location_wrt(self.deck, y="b") - elif plate.get_absolute_rotation().z % 360 == 270: - back_location = plate.get_location_wrt(self.deck, x="l") - front_location = plate.get_location_wrt(self.deck, x="r") - else: - raise ValueError("Plate rotation must be a multiple of 90 degrees") - - try: - # Then move all channels in the y-space simultaneously. - await self.position_channels_in_y_direction( - { - front_channel: front_location.y + move_inwards, - back_channel: back_location.y - move_inwards, - } - ) - - await self.move_channel_z(front_channel, z_location) - await self.move_channel_z(back_channel, z_location) - finally: - # Move channels that are lower than the `front_channel` and `back_channel` to - # the just above the foil, in case the foil pops up. - zs = await self.get_channels_z_positions() - indices = [channel_idx for channel_idx, z in zs.items() if z < z_location] - idx = { - idx: z_location + move_height for idx in indices if idx not in (front_channel, back_channel) - } - await self.position_channels_in_z_direction(idx) - - # After that, all channels are clear to move up. - await self.move_all_channels_in_z_safety() + """Deprecated: use ``star.pip.backend.step_off_foil()``.""" + await self._new_pip.step_off_foil( + wells=wells, + front_channel=front_channel, + back_channel=back_channel, + deck=self.deck, + move_inwards=move_inwards, + move_height=move_height, + ) async def request_volume_in_tip(self, channel: int) -> float: resp = await self.send_command(STARBackend.channel_id(channel), "QC", fmt="qc##### (n)") @@ -10508,20 +9950,11 @@ async def request_volume_in_tip(self, channel: int) -> float: @asynccontextmanager async def slow_iswap(self, wrist_velocity: int = 20_000, gripper_velocity: int = 20_000): - """A context manager that sets the iSWAP to slow speed during the context""" - assert 20 <= gripper_velocity <= 75_000 - assert 20 <= wrist_velocity <= 65_000 - - original_wv = (await self.send_command("R0", "RA", ra="wv", fmt="wv#####"))["wv"] - original_tv = (await self.send_command("R0", "RA", ra="tv", fmt="tv#####"))["tv"] - - await self.send_command("R0", "AA", wv=gripper_velocity) # wrist velocity - await self.send_command("R0", "AA", tv=wrist_velocity) # gripper velocity - try: + """Deprecated: use ``star.iswap.slow()``.""" + async with self._new_iswap.slow( + wrist_velocity=wrist_velocity, gripper_velocity=gripper_velocity + ): yield - finally: - await self.send_command("R0", "AA", wv=original_wv) - await self.send_command("R0", "AA", tv=original_tv) # HamiltonHeaterShakerInterface From 67583494fea5dc13f85f3b627805b7df8e710571 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Sun, 29 Mar 2026 23:01:02 -0700 Subject: [PATCH 23/69] RFC 7386 JSON Merge Patch serialization with compact format (#885) Co-authored-by: Claude Opus 4.6 --- .../capabilities/pumping/calibration.py | 7 ---- pylabrobot/capabilities/pumping/pumping.py | 8 ++++ pylabrobot/inheco/scila/scila_backend.py | 4 -- pylabrobot/io/serial.py | 15 ------- pylabrobot/io/socket.py | 14 ------- pylabrobot/legacy/centrifuge/centrifuge.py | 39 ++++++++++-------- pylabrobot/legacy/machines/backend.py | 1 + pylabrobot/legacy/storage/incubator.py | 10 ++--- .../inheco/scila/scila_backend_tests.py | 9 ++-- pylabrobot/resources/barcode.py | 8 ---- pylabrobot/resources/carrier_tests.py | 41 +++---------------- pylabrobot/resources/container_tests.py | 10 +---- pylabrobot/resources/deck.py | 2 +- pylabrobot/resources/hamilton/tip_creators.py | 13 +----- .../resources/hamilton/vantage_decks.py | 2 +- pylabrobot/resources/petri_dish.py | 2 +- pylabrobot/resources/petri_dish_tests.py | 26 +++--------- pylabrobot/resources/plate_adapter.py | 19 --------- pylabrobot/resources/plate_adapter_tests.py | 7 ---- pylabrobot/resources/resource.py | 38 ++++++++++------- pylabrobot/resources/resource_tests.py | 35 ---------------- pylabrobot/resources/rotation.py | 4 -- pylabrobot/resources/tip_rack.py | 2 +- pylabrobot/resources/tip_tests.py | 2 +- pylabrobot/resources/well_tests.py | 18 +++----- pylabrobot/tests/serializer_tests.py | 5 ++- pylabrobot/thermo_fisher/cytomat/constants.py | 4 -- .../thermo_fisher/cytomat/heraeus_backend.py | 4 -- 28 files changed, 94 insertions(+), 255 deletions(-) diff --git a/pylabrobot/capabilities/pumping/calibration.py b/pylabrobot/capabilities/pumping/calibration.py index 84c0d888c46..a94d9dba25c 100644 --- a/pylabrobot/capabilities/pumping/calibration.py +++ b/pylabrobot/capabilities/pumping/calibration.py @@ -98,13 +98,6 @@ def serialize(self) -> dict: "calibration_mode": self.calibration_mode, } - @classmethod - def deserialize(cls, data: dict) -> PumpCalibration: - return cls( - calibration=data["calibration"], - calibration_mode=data["calibration_mode"], - ) - @classmethod def load_from_json( cls, diff --git a/pylabrobot/capabilities/pumping/pumping.py b/pylabrobot/capabilities/pumping/pumping.py index fabbcc3be33..1e83cc4556e 100644 --- a/pylabrobot/capabilities/pumping/pumping.py +++ b/pylabrobot/capabilities/pumping/pumping.py @@ -22,6 +22,14 @@ def __init__( raise ValueError("Calibration may only have a single item for this pump") self.calibration = calibration + def serialize(self) -> dict: + if self.calibration is None: + return super().serialize() + return { + **super().serialize(), + "calibration": self.calibration.serialize(), + } + @need_capability_ready async def run_revolutions(self, num_revolutions: float): """Run for a given number of revolutions. diff --git a/pylabrobot/inheco/scila/scila_backend.py b/pylabrobot/inheco/scila/scila_backend.py index 95b62953870..f30ce311d43 100644 --- a/pylabrobot/inheco/scila/scila_backend.py +++ b/pylabrobot/inheco/scila/scila_backend.py @@ -119,10 +119,6 @@ def serialize(self) -> dict[str, Any]: "client_ip": self._sila_interface.client_ip, } - @classmethod - def deserialize(cls, data: dict[str, Any]) -> "SCILADriver": - return cls(scila_ip=data["scila_ip"], client_ip=data.get("client_ip")) - class SCILATemperatureBackend(TemperatureControllerBackend): """Translates TemperatureControllerBackend interface into SCILA SiLA commands.""" diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index 914d4567d19..3b925bf2c35 100644 --- a/pylabrobot/io/serial.py +++ b/pylabrobot/io/serial.py @@ -329,21 +329,6 @@ def serialize(self): "dsrdtr": self.dsrdtr, } - @classmethod - def deserialize(cls, data: dict) -> "Serial": - return cls( - human_readable_device_name=data["human_readable_device_name"], - port=data["port"], - baudrate=data["baudrate"], - bytesize=data["bytesize"], - parity=data["parity"], - stopbits=data["stopbits"], - write_timeout=data["write_timeout"], - timeout=data["timeout"], - rtscts=data["rtscts"], - dsrdtr=data["dsrdtr"], - ) - class SerialValidator(Serial): def __init__( diff --git a/pylabrobot/io/socket.py b/pylabrobot/io/socket.py index 0fcae09a6bd..575cb4d7c1d 100644 --- a/pylabrobot/io/socket.py +++ b/pylabrobot/io/socket.py @@ -99,20 +99,6 @@ def serialize(self): "write_timeout": self._write_timeout, } - @classmethod - def deserialize(cls, data: dict) -> "Socket": - kwargs = {} - if "read_timeout" in data: - kwargs["read_timeout"] = data["read_timeout"] - if "write_timeout" in data: - kwargs["write_timeout"] = data["write_timeout"] - return cls( - human_readable_device_name=data["human_readable_device_name"], - host=data["host"], - port=data["port"], - **kwargs, - ) - async def write(self, data: bytes, timeout: Optional[float] = None) -> None: """Wrapper around StreamWriter.write with lock and io logging. Does not retry on timeouts. diff --git a/pylabrobot/legacy/centrifuge/centrifuge.py b/pylabrobot/legacy/centrifuge/centrifuge.py index 1fc55261690..7bda675bcaa 100644 --- a/pylabrobot/legacy/centrifuge/centrifuge.py +++ b/pylabrobot/legacy/centrifuge/centrifuge.py @@ -134,18 +134,22 @@ def serialize(self) -> dict: @classmethod def deserialize(cls, data: dict, allow_marshal: bool = False): - backend = CentrifugeBackend.deserialize(data["backend"]) - buckets = tuple(ResourceHolder.deserialize(bucket) for bucket in data["buckets"]) - assert len(buckets) == 2 + buckets_data = data.get("buckets") + buckets = ( + tuple(ResourceHolder.deserialize(bucket) for bucket in buckets_data) if buckets_data else None + ) + if buckets is not None: + assert len(buckets) == 2 + rotation_data = data.get("rotation") return cls( - backend=backend, + backend=CentrifugeBackend.deserialize(data["backend"]), name=data["name"], size_x=data["size_x"], size_y=data["size_y"], size_z=data["size_z"], - rotation=Rotation.deserialize(data["rotation"]), - category=data["category"], - model=data["model"], + rotation=deserialize(rotation_data) if rotation_data else None, + category=data.get("category"), + model=data.get("model"), buckets=buckets, ) @@ -228,15 +232,18 @@ def serialize(self) -> dict: @classmethod def deserialize(cls, data: dict, allow_marshal: bool = False): + resource_data = data.get("resource", {}) + machine_data = data.get("machine", {}) + rotation_data = resource_data.get("rotation") return cls( - backend=LoaderBackend.deserialize(data["machine"]["backend"]), + backend=LoaderBackend.deserialize(machine_data["backend"]), centrifuge=Centrifuge.deserialize(data["centrifuge"]), - name=data["resource"]["name"], - size_x=data["resource"]["size_x"], - size_y=data["resource"]["size_y"], - size_z=data["resource"]["size_z"], - child_location=deserialize(data["resource"]["child_location"]), - rotation=deserialize(data["resource"]["rotation"]), - category=data["resource"]["category"], - model=data["resource"]["model"], + name=resource_data["name"], + size_x=resource_data["size_x"], + size_y=resource_data["size_y"], + size_z=resource_data["size_z"], + child_location=deserialize(resource_data["child_location"]), + rotation=deserialize(rotation_data) if rotation_data else None, + category=resource_data.get("category"), + model=resource_data.get("model"), ) diff --git a/pylabrobot/legacy/machines/backend.py b/pylabrobot/legacy/machines/backend.py index 1dab93ce246..686cd5bbebd 100644 --- a/pylabrobot/legacy/machines/backend.py +++ b/pylabrobot/legacy/machines/backend.py @@ -27,6 +27,7 @@ def serialize(self) -> dict: @classmethod def deserialize(cls, data: dict): + data = data.copy() class_name = data.pop("type") subclass = find_subclass(class_name, cls=cls) if subclass is None: diff --git a/pylabrobot/legacy/storage/incubator.py b/pylabrobot/legacy/storage/incubator.py index 26b207a3ce2..fd1c1acb7a4 100644 --- a/pylabrobot/legacy/storage/incubator.py +++ b/pylabrobot/legacy/storage/incubator.py @@ -196,16 +196,16 @@ def serialize(self): @classmethod def deserialize(cls, data: dict, allow_marshal: bool = False): - backend = IncubatorBackend.deserialize(data.pop("backend")) + rotation_data = data.get("rotation") return cls( - backend=backend, + backend=IncubatorBackend.deserialize(data["backend"]), name=data["name"], size_x=data["size_x"], size_y=data["size_y"], size_z=data["size_z"], racks=[PlateCarrier.deserialize(rack) for rack in data["racks"]], loading_tray_location=cast(Coordinate, deserialize(data["loading_tray_location"])), - rotation=Rotation.deserialize(data["rotation"]), - category=data["category"], - model=data["model"], + rotation=deserialize(rotation_data) if rotation_data else None, + category=data.get("category"), + model=data.get("model"), ) diff --git a/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py b/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py index 521dcb55036..3451f16c57d 100644 --- a/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py +++ b/pylabrobot/legacy/storage/inheco/scila/scila_backend_tests.py @@ -2,6 +2,7 @@ import xml.etree.ElementTree as ET from unittest.mock import AsyncMock, patch +from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.inheco.scila.inheco_sila_interface import InhecoSiLAInterface from pylabrobot.legacy.storage.inheco.scila.scila_backend import SCILABackend @@ -220,15 +221,15 @@ def test_serialize_no_client_ip(self): self.assertIsNone(data["client_ip"]) def test_deserialize(self): - data = {"scila_ip": "169.254.1.117", "client_ip": "192.168.1.10"} - SCILABackend.deserialize(data) + data = {"type": "SCILABackend", "scila_ip": "169.254.1.117", "client_ip": "192.168.1.10"} + MachineBackend.deserialize(data) self.MockInhecoSiLAInterface.assert_called_with( client_ip="192.168.1.10", machine_ip="169.254.1.117" ) def test_deserialize_no_client_ip(self): - data = {"scila_ip": "169.254.1.117"} - SCILABackend.deserialize(data) + data = {"type": "SCILABackend", "scila_ip": "169.254.1.117"} + MachineBackend.deserialize(data) self.MockInhecoSiLAInterface.assert_called_with(client_ip=None, machine_ip="169.254.1.117") diff --git a/pylabrobot/resources/barcode.py b/pylabrobot/resources/barcode.py index 25b4d1ac361..7f06252b146 100644 --- a/pylabrobot/resources/barcode.py +++ b/pylabrobot/resources/barcode.py @@ -36,13 +36,5 @@ def serialize(self) -> dict: "position_on_resource": self.position_on_resource, } - @staticmethod - def deserialize(data: dict) -> "Barcode": - return Barcode( - data=data["data"], - symbology=data["symbology"], - position_on_resource=data["position_on_resource"], - ) - def __str__(self) -> str: return f'Barcode(data="{self.data}", symbology="{self.symbology}", position_on_resource="{self.position_on_resource}")' diff --git a/pylabrobot/resources/carrier_tests.py b/pylabrobot/resources/carrier_tests.py index 0b704899824..a9dbd05f29a 100644 --- a/pylabrobot/resources/carrier_tests.py +++ b/pylabrobot/resources/carrier_tests.py @@ -221,13 +221,7 @@ def test_serialization(self): "size_x": 135.0, "size_y": 497.0, "size_z": 13.0, - "location": None, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "category": "tip_carrier", - "model": None, - "barcode": None, - "preferred_pickup_location": None, - "parent_name": None, "children": [ { "name": "tip_car-0", @@ -241,14 +235,9 @@ def test_serialization(self): "y": 20, "z": 30, }, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "category": "resource_holder", - "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, - "children": [], "parent_name": "tip_car", - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, }, { "name": "tip_car-1", @@ -262,14 +251,9 @@ def test_serialization(self): "y": 50, "z": 30, }, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "category": "resource_holder", - "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, - "children": [], "parent_name": "tip_car", - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, }, { "name": "tip_car-2", @@ -283,14 +267,9 @@ def test_serialization(self): "y": 80, "z": 30, }, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "category": "resource_holder", - "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, - "children": [], "parent_name": "tip_car", - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, }, { "name": "tip_car-3", @@ -304,14 +283,9 @@ def test_serialization(self): "y": 130, "z": 30, }, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "category": "resource_holder", - "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, - "children": [], "parent_name": "tip_car", - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, }, { "name": "tip_car-4", @@ -325,14 +299,9 @@ def test_serialization(self): "y": 160, "z": 30, }, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, "category": "resource_holder", - "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, - "children": [], "parent_name": "tip_car", - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "child_location": {"type": "Coordinate", "x": 0, "y": 0, "z": 0}, }, ], }, diff --git a/pylabrobot/resources/container_tests.py b/pylabrobot/resources/container_tests.py index 7d737092c2c..7def0eb7448 100644 --- a/pylabrobot/resources/container_tests.py +++ b/pylabrobot/resources/container_tests.py @@ -32,20 +32,12 @@ def compute_height_from_volume(volume): "size_x": 10, "size_y": 10, "size_z": 10, + "type": "Container", "material_z_thickness": 1, - "category": None, - "model": None, - "barcode": None, - "preferred_pickup_location": None, "max_volume": 1000, "compute_volume_from_height": serialize(compute_volume_from_height), "compute_height_from_volume": serialize(compute_height_from_volume), "height_volume_data": None, - "parent_name": None, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, - "type": "Container", - "children": [], - "location": None, }, ) diff --git a/pylabrobot/resources/deck.py b/pylabrobot/resources/deck.py index f2805bc7038..47e30c9d8c9 100644 --- a/pylabrobot/resources/deck.py +++ b/pylabrobot/resources/deck.py @@ -45,7 +45,7 @@ def __init__( def serialize(self) -> dict: """Serialize this deck.""" super_serialized = super().serialize() - del super_serialized["model"] # deck's don't typically have a model + super_serialized.pop("model", None) # deck's don't typically have a model return super_serialized def _check_naming_conflicts(self, resource: Resource): diff --git a/pylabrobot/resources/hamilton/tip_creators.py b/pylabrobot/resources/hamilton/tip_creators.py index 042052099a6..f51b593ddd8 100644 --- a/pylabrobot/resources/hamilton/tip_creators.py +++ b/pylabrobot/resources/hamilton/tip_creators.py @@ -90,24 +90,13 @@ def __repr__(self) -> str: def serialize(self): super_serialized = super().serialize() - del super_serialized["fitting_depth"] # inferred from tip size + super_serialized.pop("fitting_depth", None) # inferred from tip size return { **super_serialized, "pickup_method": self.pickup_method.name, "tip_size": self.tip_size.name, } - @classmethod - def deserialize(cls, data): - return HamiltonTip( - name=data["name"], - has_filter=data["has_filter"], - total_tip_length=data["total_tip_length"], - maximal_volume=data["maximal_volume"], - tip_size=TipSize[data["tip_size"]], - pickup_method=TipPickupMethod[data["pickup_method"]], - ) - def standard_volume_tip_no_filter(name: Optional[str] = None) -> HamiltonTip: """Deprecated. Use :func:`hamilton_tip_300uL` instead.""" diff --git a/pylabrobot/resources/hamilton/vantage_decks.py b/pylabrobot/resources/hamilton/vantage_decks.py index 56d06d6654b..01797975a69 100644 --- a/pylabrobot/resources/hamilton/vantage_decks.py +++ b/pylabrobot/resources/hamilton/vantage_decks.py @@ -61,5 +61,5 @@ def rails_to_location(self, rails: int) -> Coordinate: def serialize(self) -> dict: super_serialized = super().serialize() for key in ["size_x", "size_y", "size_z", "num_rails"]: - super_serialized.pop(key) + super_serialized.pop(key, None) return {"size": self.size, **super_serialized} diff --git a/pylabrobot/resources/petri_dish.py b/pylabrobot/resources/petri_dish.py index 410c105d1ee..e52052fa398 100644 --- a/pylabrobot/resources/petri_dish.py +++ b/pylabrobot/resources/petri_dish.py @@ -40,7 +40,7 @@ def __init__( def serialize(self): super_serialized = super().serialize() for key in ["size_x", "size_y", "size_z"]: - del super_serialized[key] + super_serialized.pop(key, None) return { **super_serialized, diff --git a/pylabrobot/resources/petri_dish_tests.py b/pylabrobot/resources/petri_dish_tests.py index 956f191a964..ac24ab11deb 100644 --- a/pylabrobot/resources/petri_dish_tests.py +++ b/pylabrobot/resources/petri_dish_tests.py @@ -18,22 +18,15 @@ def test_petri_dish_serialization(self): serialized, { "name": "petri_dish", + "type": "PetriDish", "category": "petri_dish", - "diameter": 90.0, - "height": 15.0, + "max_volume": 121500.0, "material_z_thickness": None, "compute_volume_from_height": None, "compute_height_from_volume": None, "height_volume_data": None, - "parent_name": None, - "type": "PetriDish", - "children": [], - "location": None, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, - "max_volume": 121500.0, - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "diameter": 90.0, + "height": 15.0, }, ) @@ -44,18 +37,11 @@ def test_petri_dish_holder_serialization(self): serialized, { "name": "petri_dish_holder", - "category": "petri_dish_holder", + "type": "PetriDishHolder", "size_x": 127.76, "size_y": 85.48, "size_z": 14.5, - "parent_name": None, - "type": "PetriDishHolder", - "children": [], - "location": None, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, - "model": None, - "barcode": None, - "preferred_pickup_location": None, + "category": "petri_dish_holder", }, ) diff --git a/pylabrobot/resources/plate_adapter.py b/pylabrobot/resources/plate_adapter.py index 164d46d5318..750b4ae7445 100644 --- a/pylabrobot/resources/plate_adapter.py +++ b/pylabrobot/resources/plate_adapter.py @@ -117,25 +117,6 @@ def serialize(self) -> dict: "plate_z_offset": self.plate_z_offset, } - @classmethod - def deserialize(cls, data: dict, allow_marshal: bool = False) -> PlateAdapter: - return cls( - name=data["name"], - size_x=data["size_x"], - size_y=data["size_y"], - size_z=data["size_z"], - dx=data["dx"], - dy=data["dy"], - dz=data["dz"], - adapter_hole_size_x=data["adapter_hole_size_x"], - adapter_hole_size_y=data["adapter_hole_size_y"], - adapter_hole_dx=data["adapter_hole_dx"], - adapter_hole_dy=data["adapter_hole_dy"], - plate_z_offset=data["plate_z_offset"], - category=data.get("category"), - model=data.get("model"), - ) - def compute_plate_location(self, resource: Plate) -> Coordinate: """Compute the location of the `Plate` child resource in relationship to the `PlateAdapter` to align the `Plate` well-grid with the adapter's hole grid.""" diff --git a/pylabrobot/resources/plate_adapter_tests.py b/pylabrobot/resources/plate_adapter_tests.py index 9d3d1f0ce8c..1dd936fe530 100644 --- a/pylabrobot/resources/plate_adapter_tests.py +++ b/pylabrobot/resources/plate_adapter_tests.py @@ -29,14 +29,7 @@ def test_plate_adapter_serialization(self): "size_x": 128.0, "size_y": 86.0, "size_z": 15.0, - "location": None, - "rotation": {"x": 0, "y": 0, "z": 0, "type": "Rotation"}, "category": "plate_adapter", - "model": None, - "barcode": None, - "preferred_pickup_location": None, - "children": [], - "parent_name": None, "dx": 0.0, "dy": 1.0, "dz": 2.0, diff --git a/pylabrobot/resources/resource.py b/pylabrobot/resources/resource.py index 2d03af454f0..9c831e31e07 100644 --- a/pylabrobot/resources/resource.py +++ b/pylabrobot/resources/resource.py @@ -125,21 +125,30 @@ def get_size_z(self) -> float: return self._local_size_z def serialize(self) -> dict: - return { + data: dict = { "name": self.name, "type": self.__class__.__name__, "size_x": self._size_x, "size_y": self._size_y, "size_z": self._size_z, - "location": serialize(self.location), - "rotation": serialize(self.rotation), - "category": self.category, - "model": self.model, - "barcode": self.barcode.serialize() if self.barcode is not None else None, - "preferred_pickup_location": serialize(self.preferred_pickup_location), - "children": [child.serialize() for child in self.children], - "parent_name": self.parent.name if self.parent is not None else None, } + if self.location is not None: + data["location"] = serialize(self.location) + if not (self.rotation.x == 0 and self.rotation.y == 0 and self.rotation.z == 0): + data["rotation"] = serialize(self.rotation) + if self.category is not None: + data["category"] = self.category + if self.model is not None: + data["model"] = self.model + if self.barcode is not None: + data["barcode"] = self.barcode.serialize() + if self.preferred_pickup_location is not None: + data["preferred_pickup_location"] = serialize(self.preferred_pickup_location) + if self.children: + data["children"] = [child.serialize() for child in self.children] + if self.parent is not None: + data["parent_name"] = self.parent.name + return data @property def name(self) -> str: @@ -750,15 +759,16 @@ def deserialize(cls, data: dict, allow_marshal: bool = False) -> Self: "parent_name", "location", ]: # delete meta keys - del data_copy[key] - children_data = data_copy.pop("children") - rotation = data_copy.pop("rotation") + data_copy.pop(key, None) + children_data = data_copy.pop("children", []) + rotation = data_copy.pop("rotation", None) barcode = data_copy.pop("barcode", None) preferred_pickup_location = data_copy.pop("preferred_pickup_location", None) resource = subclass(**deserialize(data_copy, allow_marshal=allow_marshal)) - resource.rotation = Rotation.deserialize(rotation) # not pretty, should be done in init. + if rotation is not None: + resource.rotation = deserialize(rotation) # not pretty, should be done in init. if barcode is not None: - resource.barcode = Barcode.deserialize(barcode) + resource.barcode = Barcode(**barcode) if preferred_pickup_location is not None: resource.preferred_pickup_location = cast(Coordinate, deserialize(preferred_pickup_location)) diff --git a/pylabrobot/resources/resource_tests.py b/pylabrobot/resources/resource_tests.py index 9ef3c987edb..7b393633d0a 100644 --- a/pylabrobot/resources/resource_tests.py +++ b/pylabrobot/resources/resource_tests.py @@ -224,27 +224,15 @@ def test_serialize(self): r.serialize(), { "name": "test", - "location": None, - "rotation": { - "type": "Rotation", - "x": 0, - "y": 0, - "z": 0, - }, "size_x": 10, "size_y": 10, "size_z": 10, "type": "Resource", - "children": [], - "category": None, - "parent_name": None, - "model": None, "barcode": { "data": "1234567890", "symbology": "code128", "position_on_resource": "left", }, - "preferred_pickup_location": None, }, ) @@ -257,13 +245,6 @@ def test_child_serialize(self): r.serialize(), { "name": "test", - "location": None, - "rotation": { - "type": "Rotation", - "x": 0, - "y": 0, - "z": 0, - }, "size_x": 10, "size_y": 10, "size_z": 10, @@ -277,29 +258,13 @@ def test_child_serialize(self): "y": 5, "z": 5, }, - "rotation": { - "type": "Rotation", - "x": 0, - "y": 0, - "z": 0, - }, "size_x": 1, "size_y": 1, "size_z": 1, "type": "Resource", - "children": [], - "category": None, "parent_name": "test", - "model": None, - "barcode": None, - "preferred_pickup_location": None, } ], - "category": None, - "parent_name": None, - "model": None, - "barcode": None, - "preferred_pickup_location": None, }, ) diff --git a/pylabrobot/resources/rotation.py b/pylabrobot/resources/rotation.py index 26cce995477..59b7e822adb 100644 --- a/pylabrobot/resources/rotation.py +++ b/pylabrobot/resources/rotation.py @@ -63,10 +63,6 @@ def __str__(self) -> str: def __add__(self, other) -> "Rotation": return Rotation(x=self.x + other.x, y=self.y + other.y, z=self.z + other.z) - @staticmethod - def deserialize(data) -> "Rotation": - return Rotation(data["x"], data["y"], data["z"]) - def __repr__(self) -> str: return self.__str__() diff --git a/pylabrobot/resources/tip_rack.py b/pylabrobot/resources/tip_rack.py index 149b90c445a..9c0c94eca1b 100644 --- a/pylabrobot/resources/tip_rack.py +++ b/pylabrobot/resources/tip_rack.py @@ -122,7 +122,7 @@ def make_tip(name: str) -> Tip: name=data["name"], size_x=data["size_x"], size_y=data["size_y"], - size_z=data["size_z"], + size_z=data.get("size_z", 0), make_tip=make_tip, category=data.get("category", "tip_spot"), ) diff --git a/pylabrobot/resources/tip_tests.py b/pylabrobot/resources/tip_tests.py index 668c78cbae3..f2a1b15928b 100644 --- a/pylabrobot/resources/tip_tests.py +++ b/pylabrobot/resources/tip_tests.py @@ -61,4 +61,4 @@ def test_deserialize_subclass(self): TipPickupMethod.OUT_OF_RACK, name="test_tip", ) - self.assertEqual(HamiltonTip.deserialize(tip.serialize()), tip) + self.assertEqual(deserialize(tip.serialize()), tip) diff --git a/pylabrobot/resources/well_tests.py b/pylabrobot/resources/well_tests.py index f322d88521b..f42e6120199 100644 --- a/pylabrobot/resources/well_tests.py +++ b/pylabrobot/resources/well_tests.py @@ -23,22 +23,16 @@ def test_serialize(self): "size_x": 1, "size_y": 2, "size_z": 3, - "material_z_thickness": None, - "bottom_type": "flat", - "cross_section_type": "circle", - "max_volume": 10, - "model": "model", - "barcode": None, - "preferred_pickup_location": None, - "category": "well", - "children": [], "type": "Well", - "parent_name": None, - "location": None, - "rotation": {"type": "Rotation", "x": 0, "y": 0, "z": 0}, + "category": "well", + "model": "model", + "max_volume": 10, + "material_z_thickness": None, "compute_volume_from_height": None, "compute_height_from_volume": None, "height_volume_data": None, + "bottom_type": "flat", + "cross_section_type": "circle", }, ) diff --git a/pylabrobot/tests/serializer_tests.py b/pylabrobot/tests/serializer_tests.py index 356ae12cafd..8b8e3510816 100644 --- a/pylabrobot/tests/serializer_tests.py +++ b/pylabrobot/tests/serializer_tests.py @@ -1,6 +1,9 @@ import math -from pylabrobot.serializer import deserialize, serialize +from pylabrobot.serializer import ( + deserialize, + serialize, +) def test_serialize_deserialize_closure(): diff --git a/pylabrobot/thermo_fisher/cytomat/constants.py b/pylabrobot/thermo_fisher/cytomat/constants.py index 29f68ec0c2e..410f88108ba 100644 --- a/pylabrobot/thermo_fisher/cytomat/constants.py +++ b/pylabrobot/thermo_fisher/cytomat/constants.py @@ -137,10 +137,6 @@ class CytomatRack: num_slots: int # number of plate locations in rack pitch: int # distance between 2 plate locations - @classmethod - def deserialize(cls, data: dict): - return cls(num_slots=data["num_slots"], pitch=data["pitch"]) - class CytomatType(Enum): C6000 = "C6000" diff --git a/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py index 24eb0d5dcc7..386dee81e16 100644 --- a/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py +++ b/pylabrobot/thermo_fisher/cytomat/heraeus_backend.py @@ -238,7 +238,3 @@ def serialize(self) -> dict: **Driver.serialize(self), "port": self.io.port, } - - @classmethod - def deserialize(cls, data: dict): - return cls(port=data["port"]) From 6f7f7b53a2eac468a51d3f737df12727358116c8 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 30 Mar 2026 17:40:42 -0700 Subject: [PATCH 24/69] Add PlateWashingCapability for manifold-based plate washers (#980) Co-authored-by: Claude Opus 4.6 (1M context) --- .../capabilities/plate_washing/__init__.py | 2 + .../capabilities/plate_washing/backend.py | 41 +++++++++++++++ .../plate_washing/plate_washing.py | 50 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 pylabrobot/capabilities/plate_washing/__init__.py create mode 100644 pylabrobot/capabilities/plate_washing/backend.py create mode 100644 pylabrobot/capabilities/plate_washing/plate_washing.py diff --git a/pylabrobot/capabilities/plate_washing/__init__.py b/pylabrobot/capabilities/plate_washing/__init__.py new file mode 100644 index 00000000000..0a944d0c2ab --- /dev/null +++ b/pylabrobot/capabilities/plate_washing/__init__.py @@ -0,0 +1,2 @@ +from .backend import PlateWashingBackend +from .plate_washing import PlateWashingCapability diff --git a/pylabrobot/capabilities/plate_washing/backend.py b/pylabrobot/capabilities/plate_washing/backend.py new file mode 100644 index 00000000000..6518a4d2293 --- /dev/null +++ b/pylabrobot/capabilities/plate_washing/backend.py @@ -0,0 +1,41 @@ +from abc import ABCMeta, abstractmethod +from typing import Optional + +from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.resources import Plate + + +class PlateWashingBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for plate washing devices.""" + + @abstractmethod + async def aspirate(self, plate: Plate) -> None: + """Aspirate (remove) liquid from all wells.""" + + @abstractmethod + async def dispense(self, plate: Plate, volume: float) -> None: + """Dispense liquid into all wells. + + Args: + plate: Target plate. + volume: Volume per well in uL. + """ + + @abstractmethod + async def wash( + self, + plate: Plate, + cycles: int = 3, + dispense_volume: Optional[float] = None, + ) -> None: + """Perform wash cycles (repeated dispense + aspirate). + + Args: + plate: Target plate. + cycles: Number of wash cycles. + dispense_volume: Volume per well per cycle in uL. If None, use device default. + """ + + @abstractmethod + async def prime(self) -> None: + """Prime fluid lines.""" diff --git a/pylabrobot/capabilities/plate_washing/plate_washing.py b/pylabrobot/capabilities/plate_washing/plate_washing.py new file mode 100644 index 00000000000..7454f0cf1bc --- /dev/null +++ b/pylabrobot/capabilities/plate_washing/plate_washing.py @@ -0,0 +1,50 @@ +from typing import Optional + +from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.resources import Plate + +from .backend import PlateWashingBackend + + +class PlateWashingCapability(Capability): + """Plate washing capability.""" + + def __init__(self, backend: PlateWashingBackend): + super().__init__(backend=backend) + self.backend: PlateWashingBackend = backend + + @need_capability_ready + async def aspirate(self, plate: Plate) -> None: + """Aspirate (remove) liquid from all wells.""" + await self.backend.aspirate(plate=plate) + + @need_capability_ready + async def dispense(self, plate: Plate, volume: float) -> None: + """Dispense liquid into all wells. + + Args: + plate: Target plate. + volume: Volume per well in uL. + """ + await self.backend.dispense(plate=plate, volume=volume) + + @need_capability_ready + async def wash( + self, + plate: Plate, + cycles: int = 3, + dispense_volume: Optional[float] = None, + ) -> None: + """Perform wash cycles (repeated dispense + aspirate). + + Args: + plate: Target plate. + cycles: Number of wash cycles. + dispense_volume: Volume per well per cycle in uL. If None, use device default. + """ + await self.backend.wash(plate=plate, cycles=cycles, dispense_volume=dispense_volume) + + @need_capability_ready + async def prime(self) -> None: + """Prime fluid lines.""" + await self.backend.prime() From 31998a7cd1440bc76c6cf415c410566ddb1464a3 Mon Sep 17 00:00:00 2001 From: Robert-Keyser-Calico <51971252+Robert-Keyser-Calico@users.noreply.github.com> Date: Mon, 30 Mar 2026 17:58:47 -0700 Subject: [PATCH 25/69] Add bulk dispenser module with Multidrop Combi backend support (#964) Co-authored-by: Claude Opus 4.6 (1M context) --- creating-capabilities.md | 16 +- docs/_exts/plr_devices/__init__.py | 1 + docs/_exts/plr_devices/directive.py | 124 ++++ docs/_static/devices.json | 690 ++++++++++++++++++ docs/api/pylabrobot.arms.rst | 42 ++ docs/api/pylabrobot.capabilities.rst | 268 +++++++ docs/api/pylabrobot.centrifuge.rst | 24 - docs/api/pylabrobot.heating_shaking.rst | 28 - docs/api/pylabrobot.io.sila.rst | 17 - .../pylabrobot.liquid_handling.backends.rst | 41 -- docs/api/pylabrobot.liquid_handling.rst | 52 -- .../pylabrobot.liquid_handling.strictness.rst | 12 - docs/api/pylabrobot.machine.rst | 11 - docs/api/pylabrobot.only_fans.rst | 26 - docs/api/pylabrobot.plate_reading.rst | 29 - docs/api/pylabrobot.pumps.rst | 25 - docs/api/pylabrobot.resources.rst | 3 + docs/api/pylabrobot.rst | 26 +- docs/api/pylabrobot.scales.rst | 25 - docs/api/pylabrobot.shaking.rst | 25 - .../pylabrobot.temperature_controlling.rst | 29 - docs/api/pylabrobot.thermocycling.rst | 28 - docs/api/pylabrobot.tilting.rst | 27 - docs/conf.py | 1 + docs/user_guide/capabilities/absorbance.ipynb | 84 +++ docs/user_guide/capabilities/arms.md | 77 ++ .../capabilities/automated-retrieval.ipynb | 71 ++ .../capabilities/barcode-scanning.ipynb | 69 ++ .../capabilities/centrifuging.ipynb | 111 +++ .../user_guide/capabilities/fan-control.ipynb | 91 +++ .../capabilities/fluorescence.ipynb | 77 ++ docs/user_guide/capabilities/head96.md | 66 ++ .../capabilities/humidity-control.ipynb | 71 ++ docs/user_guide/capabilities/index.md | 65 ++ .../capabilities/luminescence.ipynb | 72 ++ docs/user_guide/capabilities/microscopy.md | 93 +++ docs/user_guide/capabilities/peeling.ipynb | 64 ++ docs/user_guide/capabilities/pip.md | 89 +++ docs/user_guide/capabilities/pumping.ipynb | 100 +++ docs/user_guide/capabilities/sealing.ipynb | 90 +++ docs/user_guide/capabilities/shaking.ipynb | 117 +++ .../capabilities/temperature-control.ipynb | 127 ++++ docs/user_guide/capabilities/tilting.ipynb | 92 +++ docs/user_guide/capabilities/weighing.md | 96 +++ docs/user_guide/index.md | 8 + pylabrobot/agilent/__init__.py | 1 - pylabrobot/agilent/biotek/__init__.py | 1 - pylabrobot/agilent/biotek/biotek.py | 10 +- pylabrobot/agilent/biotek/cytation.py | 43 +- pylabrobot/agilent/biotek/synergy_h1.py | 22 +- pylabrobot/agilent/vspin/vspin.py | 132 ++-- .../agrowpumps/agrowdosepump_backend.py | 32 +- pylabrobot/agrowpumps/agrowdosepump_tests.py | 18 +- pylabrobot/arms/__init__.py | 1 + pylabrobot/arms/articulated_arm.py | 167 +++++ pylabrobot/arms/orientable_arm.py | 31 +- pylabrobot/azenta/a4s.py | 40 +- pylabrobot/azenta/xpeel.py | 12 +- .../clariostar/absorbance_backend.py | 14 +- .../bmg_labtech/clariostar/clariostar.py | 18 +- .../clariostar/fluorescence_backend.py | 2 +- .../clariostar/luminescence_backend.py | 14 +- pylabrobot/brooks/precise_flex.py | 182 ++--- pylabrobot/bulk_dispensers/__init__.py | 3 + pylabrobot/bulk_dispensers/backend.py | 84 +++ pylabrobot/bulk_dispensers/bulk_dispenser.py | 60 ++ pylabrobot/bulk_dispensers/chatterbox.py | 47 ++ pylabrobot/bulk_dispensers/tests/__init__.py | 0 .../tests/bulk_dispenser_tests.py | 91 +++ .../thermo_scientific/__init__.py | 13 + .../multidrop_combi/__init__.py | 19 + .../multidrop_combi/actions.py | 30 + .../multidrop_combi/backend.py | 123 ++++ .../multidrop_combi/commands.py | 251 +++++++ .../multidrop_combi/communication.py | 223 ++++++ .../multidrop_combi/demo_multidrop.py | 164 +++++ .../multidrop_combi/enums.py | 25 + .../multidrop_combi/errors.py | 25 + .../multidrop_combi/helpers.py | 139 ++++ .../multidrop_combi/queries.py | 50 ++ .../multidrop_combi/tests/__init__.py | 0 .../multidrop_combi/tests/backend_tests.py | 63 ++ .../multidrop_combi/tests/commands_tests.py | 168 +++++ .../tests/communication_tests.py | 162 ++++ .../multidrop_combi/tests/helpers_tests.py | 209 ++++++ pylabrobot/byonoy/absorbance_96.py | 6 +- pylabrobot/byonoy/luminescence_96.py | 6 +- .../automated_retrieval/__init__.py | 2 +- .../automated_retrieval.py | 2 +- .../automated_retrieval/chatterbox.py | 18 + .../capabilities/barcode_scanning/__init__.py | 2 +- .../barcode_scanning/barcode_scanning.py | 2 +- .../barcode_scanning/chatterbox.py | 18 + .../capabilities/centrifuging/__init__.py | 2 +- .../capabilities/centrifuging/centrifuging.py | 2 +- .../capabilities/centrifuging/chatterbox.py | 44 ++ .../capabilities/fan_control/__init__.py | 2 +- .../capabilities/fan_control/chatterbox.py | 15 + .../capabilities/fan_control/fan_control.py | 2 +- .../humidity_controlling/__init__.py | 2 +- .../humidity_controlling/chatterbox.py | 23 + .../humidity_controller.py | 2 +- .../capabilities/liquid_handling/__init__.py | 2 +- .../capabilities/liquid_handling/head96.py | 2 +- .../capabilities/microscopy/__init__.py | 2 +- .../capabilities/microscopy/chatterbox.py | 1 + .../capabilities/microscopy/microscopy.py | 2 +- .../microscopy/microscopy_tests.py | 12 +- pylabrobot/capabilities/peeling/__init__.py | 2 +- pylabrobot/capabilities/peeling/chatterbox.py | 18 + pylabrobot/capabilities/peeling/peeling.py | 2 +- .../plate_reading/absorbance/__init__.py | 2 +- .../plate_reading/absorbance/absorbance.py | 2 +- .../absorbance/absorbance_tests.py | 12 +- .../plate_reading/fluorescence/__init__.py | 2 +- .../fluorescence/fluorescence.py | 2 +- .../fluorescence/fluorescence_tests.py | 12 +- .../plate_reading/luminescence/__init__.py | 2 +- .../luminescence/luminescence.py | 2 +- .../luminescence/luminescence_tests.py | 12 +- pylabrobot/capabilities/pumping/__init__.py | 2 +- pylabrobot/capabilities/pumping/pumping.py | 2 +- .../capabilities/pumping/pumping_tests.py | 8 +- pylabrobot/capabilities/sealing/__init__.py | 2 +- pylabrobot/capabilities/sealing/chatterbox.py | 18 + pylabrobot/capabilities/sealing/sealing.py | 2 +- pylabrobot/capabilities/shaking/__init__.py | 2 +- pylabrobot/capabilities/shaking/chatterbox.py | 25 + pylabrobot/capabilities/shaking/shaking.py | 2 +- .../temperature_controlling/__init__.py | 2 +- .../temperature_controlling/chatterbox.py | 27 + .../temperature_controller.py | 2 +- pylabrobot/capabilities/tilting/__init__.py | 2 +- pylabrobot/capabilities/tilting/chatterbox.py | 12 + pylabrobot/capabilities/tilting/tilting.py | 2 +- pylabrobot/capabilities/weighing/__init__.py | 2 +- .../capabilities/weighing/chatterbox.py | 23 + pylabrobot/capabilities/weighing/weighing.py | 2 +- pylabrobot/cole_parmer/masterflex_backend.py | 16 +- pylabrobot/device.py | 8 +- pylabrobot/hamilton/heater_shaker/backend.py | 24 +- .../hamilton/heater_shaker/heater_shaker.py | 10 +- .../hamilton/liquid_handlers/star/autoload.py | 132 ++-- .../liquid_handlers/star/chatterbox.py | 26 +- .../hamilton/liquid_handlers/star/core.py | 8 +- .../hamilton/liquid_handlers/star/cover.py | 32 +- .../hamilton/liquid_handlers/star/driver.py | 271 +++---- .../liquid_handlers/star/head96_backend.py | 26 +- .../hamilton/liquid_handlers/star/iswap.py | 140 ++-- .../liquid_handlers/star/misc/architecture.md | 2 +- .../liquid_handlers/star/pip_backend.py | 215 +++--- .../hamilton/liquid_handlers/star/star.py | 29 +- .../star/tests/autoload_tests.py | 18 +- .../liquid_handlers/star/tests/cover_tests.py | 8 +- .../star/tests/legacy_parity_tests.py | 191 ----- .../star/tests/wash_station_tests.py | 24 +- .../liquid_handlers/star/tests/x_arm_tests.py | 10 +- .../liquid_handlers/star/wash_station.py | 38 +- .../hamilton/liquid_handlers/star/x_arm.py | 24 +- pylabrobot/hamilton/only_fans/backend.py | 8 +- pylabrobot/hamilton/only_fans/hepa_fan.py | 6 +- pylabrobot/hamilton/tilt_module/backend.py | 40 +- .../hamilton/tilt_module/tilt_module.py | 6 +- pylabrobot/inheco/cpac.py | 6 +- pylabrobot/inheco/scila/scila.py | 6 +- pylabrobot/inheco/scila/scila_backend.py | 8 +- pylabrobot/inheco/thermoshake.py | 10 +- pylabrobot/keyence/keyence_backend.py | 6 +- pylabrobot/keyence/keyence_barcode_scanner.py | 6 +- .../barcode_scanners/barcode_scanner.py | 2 +- .../keyence/keyence_backend.py | 8 +- pylabrobot/legacy/centrifuge/vspin_backend.py | 54 +- .../heating_shaking/bioshake_backend.py | 16 +- .../heating_shaking/hamilton_backend.py | 12 +- .../backends/hamilton/STAR_backend.py | 108 +-- .../legacy/liquid_handling/liquid_handler.py | 6 +- .../molecular_devices/pico/backend.py | 16 +- pylabrobot/legacy/only_fans/fan.py | 4 +- .../only_fans/hamilton_hepa_fan_backend.py | 8 +- pylabrobot/legacy/peeling/xpeel_backend.py | 38 +- .../bmg_labtech/clario_star_backend.py | 18 +- pylabrobot/legacy/plate_reading/imager.py | 6 +- .../molecular_devices/backend.py | 24 +- .../molecular_devices/backend_tests.py | 10 +- .../spectramax_384_plus_backend.py | 2 +- .../legacy/plate_reading/plate_reader.py | 12 +- .../pumps/agrowpumps/agrowdosepump_backend.py | 18 +- .../pumps/agrowpumps/agrowdosepump_tests.py | 4 +- .../pumps/cole_parmer/masterflex_backend.py | 16 +- pylabrobot/legacy/pumps/pump.py | 4 +- pylabrobot/legacy/pumps/pumparray.py | 6 +- .../legacy/scales/mettler_toledo_backend.py | 16 +- pylabrobot/legacy/sealing/a4s_backend.py | 22 +- pylabrobot/legacy/shaking/shaker.py | 6 +- .../storage/inheco/scila/scila_backend.py | 28 +- .../opentrons_backend.py | 10 +- .../opentrons_backend_usb.py | 10 +- .../temperature_controller.py | 4 +- .../thermocycling/opentrons_backend_usb.py | 86 +-- pylabrobot/legacy/tilting/tilter.py | 4 +- pylabrobot/liconic/liconic.py | 24 +- pylabrobot/mettler_toledo/mettler_toledo.py | 28 +- .../imageXpress/pico/backend.py | 30 +- .../imageXpress/pico/pico.py | 6 +- .../molecular_devices/spectramax/backend.py | 84 +-- .../spectramax/spectramax_384_plus.py | 12 +- .../spectramax/spectramax_m5.py | 30 +- .../temperature_module/http_driver.py | 10 +- .../temperature_module/temperature_module.py | 6 +- .../temperature_module/usb_driver.py | 8 +- pylabrobot/qinstruments/bioshake.py | 56 +- pylabrobot/thermo_fisher/cytomat/cytomat.py | 30 +- 212 files changed, 6716 insertions(+), 1907 deletions(-) create mode 100644 docs/_exts/plr_devices/__init__.py create mode 100644 docs/_exts/plr_devices/directive.py create mode 100644 docs/_static/devices.json create mode 100644 docs/api/pylabrobot.arms.rst create mode 100644 docs/api/pylabrobot.capabilities.rst delete mode 100644 docs/api/pylabrobot.centrifuge.rst delete mode 100644 docs/api/pylabrobot.heating_shaking.rst delete mode 100644 docs/api/pylabrobot.io.sila.rst delete mode 100644 docs/api/pylabrobot.liquid_handling.backends.rst delete mode 100644 docs/api/pylabrobot.liquid_handling.rst delete mode 100644 docs/api/pylabrobot.liquid_handling.strictness.rst delete mode 100644 docs/api/pylabrobot.machine.rst delete mode 100644 docs/api/pylabrobot.only_fans.rst delete mode 100644 docs/api/pylabrobot.plate_reading.rst delete mode 100644 docs/api/pylabrobot.pumps.rst delete mode 100644 docs/api/pylabrobot.scales.rst delete mode 100644 docs/api/pylabrobot.shaking.rst delete mode 100644 docs/api/pylabrobot.temperature_controlling.rst delete mode 100644 docs/api/pylabrobot.thermocycling.rst delete mode 100644 docs/api/pylabrobot.tilting.rst create mode 100644 docs/user_guide/capabilities/absorbance.ipynb create mode 100644 docs/user_guide/capabilities/arms.md create mode 100644 docs/user_guide/capabilities/automated-retrieval.ipynb create mode 100644 docs/user_guide/capabilities/barcode-scanning.ipynb create mode 100644 docs/user_guide/capabilities/centrifuging.ipynb create mode 100644 docs/user_guide/capabilities/fan-control.ipynb create mode 100644 docs/user_guide/capabilities/fluorescence.ipynb create mode 100644 docs/user_guide/capabilities/head96.md create mode 100644 docs/user_guide/capabilities/humidity-control.ipynb create mode 100644 docs/user_guide/capabilities/index.md create mode 100644 docs/user_guide/capabilities/luminescence.ipynb create mode 100644 docs/user_guide/capabilities/microscopy.md create mode 100644 docs/user_guide/capabilities/peeling.ipynb create mode 100644 docs/user_guide/capabilities/pip.md create mode 100644 docs/user_guide/capabilities/pumping.ipynb create mode 100644 docs/user_guide/capabilities/sealing.ipynb create mode 100644 docs/user_guide/capabilities/shaking.ipynb create mode 100644 docs/user_guide/capabilities/temperature-control.ipynb create mode 100644 docs/user_guide/capabilities/tilting.ipynb create mode 100644 docs/user_guide/capabilities/weighing.md create mode 100644 pylabrobot/arms/articulated_arm.py create mode 100644 pylabrobot/bulk_dispensers/__init__.py create mode 100644 pylabrobot/bulk_dispensers/backend.py create mode 100644 pylabrobot/bulk_dispensers/bulk_dispenser.py create mode 100644 pylabrobot/bulk_dispensers/chatterbox.py create mode 100644 pylabrobot/bulk_dispensers/tests/__init__.py create mode 100644 pylabrobot/bulk_dispensers/tests/bulk_dispenser_tests.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/__init__.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/__init__.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/actions.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/backend.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/commands.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/communication.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/demo_multidrop.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/enums.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/errors.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/helpers.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/queries.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/__init__.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/backend_tests.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/commands_tests.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/communication_tests.py create mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/helpers_tests.py create mode 100644 pylabrobot/capabilities/automated_retrieval/chatterbox.py create mode 100644 pylabrobot/capabilities/barcode_scanning/chatterbox.py create mode 100644 pylabrobot/capabilities/centrifuging/chatterbox.py create mode 100644 pylabrobot/capabilities/fan_control/chatterbox.py create mode 100644 pylabrobot/capabilities/humidity_controlling/chatterbox.py create mode 100644 pylabrobot/capabilities/peeling/chatterbox.py create mode 100644 pylabrobot/capabilities/sealing/chatterbox.py create mode 100644 pylabrobot/capabilities/shaking/chatterbox.py create mode 100644 pylabrobot/capabilities/temperature_controlling/chatterbox.py create mode 100644 pylabrobot/capabilities/tilting/chatterbox.py create mode 100644 pylabrobot/capabilities/weighing/chatterbox.py delete mode 100644 pylabrobot/hamilton/liquid_handlers/star/tests/legacy_parity_tests.py diff --git a/creating-capabilities.md b/creating-capabilities.md index a057d4920c1..38342d71d6d 100644 --- a/creating-capabilities.md +++ b/creating-capabilities.md @@ -121,7 +121,7 @@ for initialization that must happen after the driver is connected. from pylabrobot.capabilities.capability import Capability from .backend import ShakerBackend -class ShakingCapability(Capability): +class Shaker(Capability): def __init__(self, backend: ShakerBackend): super().__init__(backend=backend) self.backend: ShakerBackend = backend # narrow the type @@ -143,7 +143,7 @@ The `self.backend: ShakerBackend = backend` line narrows the type from `Capabili ```python from .backend import ShakerBackend -from . import ShakingCapability +from . import Shaker ``` ## Implementing a vendor device @@ -194,7 +194,7 @@ class MyFanFanBackend(FanBackend): `pylabrobot//.py`: ```python -from pylabrobot.capabilities.fan_control import FanControlCapability +from pylabrobot.capabilities.fan_control import Fan from pylabrobot.device import Device from .backend import MyFanDriver, MyFanFanBackend @@ -203,7 +203,7 @@ class MyFan(Device): driver = MyFanDriver(port=port) super().__init__(driver=driver) self._driver: MyFanDriver = driver - self.fan = FanControlCapability(backend=MyFanFanBackend(driver)) + self.fan = Fan(backend=MyFanFanBackend(driver)) self._capabilities = [self.fan] ``` @@ -276,8 +276,8 @@ class BioShake3000T(PlateHolder, Device): PlateHolder.__init__(self, name=name, ...) Device.__init__(self, driver=driver) self._driver: BioShakeDriver = driver - self.tc = TemperatureControlCapability(backend=BioShakeTemperatureBackend(driver)) - self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) + self.tc = TemperatureController(backend=BioShakeTemperatureBackend(driver)) + self.shaker = Shaker(backend=BioShakeShakerBackend(driver)) self._capabilities = [self.tc, self.shaker] ``` @@ -341,7 +341,7 @@ To test a capability without a real device, create it directly and call `_on_set ```python async def test_something(self): backend = MyFanChatterboxBackend() - cap = FanControlCapability(backend=backend) + cap = Fan(backend=backend) await cap._on_setup() await cap.turn_on(intensity=50) ``` @@ -353,7 +353,7 @@ async def test_something(self): | Driver | `Driver` | `BioShakeDriver`, `PicoDriver` | | Capability backend | `Backend` | `BioShakeShakerBackend`, `PicoMicroscopyBackend` | | Chatterbox backend | `ChatterboxBackend` or `ChatterboxBackend` | `HamiltonHepaFanChatterboxBackend` | -| Capability (abstract) | `Capability` | `ShakingCapability`, `FanControlCapability` | +| Capability (abstract) | `Capability` | `Shaker`, `Fan` | | Capability backend (abstract) | `Backend` | `ShakerBackend`, `FanBackend` | | Device | `` or product name | `HamiltonHepaFan`, `BioShake3000T`, `Pico` | diff --git a/docs/_exts/plr_devices/__init__.py b/docs/_exts/plr_devices/__init__.py new file mode 100644 index 00000000000..7ace642a06d --- /dev/null +++ b/docs/_exts/plr_devices/__init__.py @@ -0,0 +1 @@ +from .directive import setup # re-export for Sphinx diff --git a/docs/_exts/plr_devices/directive.py b/docs/_exts/plr_devices/directive.py new file mode 100644 index 00000000000..d5977537150 --- /dev/null +++ b/docs/_exts/plr_devices/directive.py @@ -0,0 +1,124 @@ +"""Sphinx directive that renders a 'Supported hardware' table from devices.json. + +Usage in MyST markdown:: + + ```{supported-devices} shaking + ``` + +Or with multiple capabilities:: + + ```{supported-devices} heating, shaking + ``` + +The directive filters devices.json to rows where the device's ``capabilities`` +list intersects with the requested set, then renders a native docutils table +styled by the active Sphinx theme. +""" + +import json +from pathlib import Path + +from docutils import nodes +from docutils.parsers.rst import Directive + + +_DEVICES = None + + +def _load_devices(): + global _DEVICES + if _DEVICES is None: + json_path = Path(__file__).resolve().parents[2] / "_static" / "devices.json" + with open(json_path, encoding="utf-8") as f: + _DEVICES = json.load(f) + return _DEVICES + + +class SupportedDevices(Directive): + """Render a table of devices that have the requested capabilities.""" + + has_content = False + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = True + + def run(self): + requested = {c.strip() for c in self.arguments[0].split(",")} + devices = _load_devices() + matches = [ + d for d in devices if requested & set(d.get("capabilities", [])) + ] + + if not matches: + para = nodes.paragraph( + text=f"No supported devices found for: {', '.join(requested)}" + ) + return [para] + + matches.sort(key=lambda d: (d["vendor"], d["name"])) + + # Build a native docutils table + table = nodes.table() + table["classes"].append("table") + + tgroup = nodes.tgroup(cols=4) + table += tgroup + for _ in range(4): + tgroup += nodes.colspec() + + # Header + thead = nodes.thead() + tgroup += thead + header_row = nodes.row() + thead += header_row + for title in ("Device", "Vendor", "Status", "Links"): + entry = nodes.entry() + entry += nodes.paragraph(text=title) + header_row += entry + + # Body + tbody = nodes.tbody() + tgroup += tbody + for d in matches: + row = nodes.row() + tbody += row + + # Device name (bold) + name_entry = nodes.entry() + name_entry += nodes.strong(text=d["name"]) + row += name_entry + + # Vendor + vendor_entry = nodes.entry() + vendor_entry += nodes.paragraph(text=d["vendor"]) + row += vendor_entry + + # Status + status_entry = nodes.entry() + status_entry += nodes.paragraph(text=d.get("status", "")) + row += status_entry + + # Links + links_entry = nodes.entry() + link_nodes = [] + if d.get("docs"): + ref = nodes.reference("", "docs", refuri=d["docs"]) + link_nodes.append(ref) + if d.get("oem"): + if link_nodes: + link_nodes.append(nodes.Text(" · ")) + ref = nodes.reference("", "oem", refuri=d["oem"]) + link_nodes.append(ref) + if link_nodes: + para = nodes.paragraph() + for n in link_nodes: + para += n + links_entry += para + row += links_entry + + return [table] + + +def setup(app): + app.add_directive("supported-devices", SupportedDevices) + return {"version": "0.2", "parallel_read_safe": True} diff --git a/docs/_static/devices.json b/docs/_static/devices.json new file mode 100644 index 00000000000..05757bd8af1 --- /dev/null +++ b/docs/_static/devices.json @@ -0,0 +1,690 @@ +[ + { + "vendor": "Hamilton", + "name": "STAR(let)", + "capabilities": [ + "liquid handling", + "arm" + ], + "status": "Full", + "docs": "/user_guide/00_liquid-handling/hamilton-star/_hamilton-star.html", + "oem": "https://www.hamiltoncompany.com/microlab-star" + }, + { + "vendor": "Hamilton", + "name": "Vantage", + "capabilities": [ + "liquid handling", + "arm" + ], + "status": "Mostly", + "docs": "/user_guide/00_liquid-handling/hamilton-vantage/_hamilton-vantage.html", + "oem": "https://www.hamiltoncompany.com/microlab-vantage" + }, + { + "vendor": "Hamilton", + "name": "Prep", + "capabilities": [ + "liquid handling" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.hamiltoncompany.com/microlab-prep" + }, + { + "vendor": "Hamilton", + "name": "Nimbus", + "capabilities": [ + "liquid handling", + "arm" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.hamiltoncompany.com/microlab-nimbus" + }, + { + "vendor": "Tecan", + "name": "Freedom EVO", + "capabilities": [ + "liquid handling", + "arm" + ], + "status": "Basic", + "docs": "/user_guide/00_liquid-handling/tecan-evo/_tecan-evo.html", + "oem": "https://lifesciences.tecan.com/freedom-evo-platform" + }, + { + "vendor": "Opentrons", + "name": "OT-2", + "capabilities": [ + "liquid handling" + ], + "status": "Mostly", + "docs": "/user_guide/00_liquid-handling/opentrons-ot2/_opentrons-ot2.html", + "oem": "https://opentrons.com/products/ot-2-robot" + }, + { + "vendor": "Cole Parmer", + "name": "Masterflex L/S", + "capabilities": [ + "pumping" + ], + "status": "Full", + "docs": "/user_guide/00_liquid-handling/pumps/cole-parmer-masterflex.html", + "oem": "https://www.masterflex.nl/assets/uploads/2017/09/07551-xx.pdf" + }, + { + "vendor": "Agrowtek", + "name": "Pump Array", + "capabilities": [ + "pumping" + ], + "status": "Full", + "docs": null, + "oem": "https://www.agrowtek.com/index.php/products/dosing_systems/dosing-pumps/agrowdose-adi-digital-persitaltic-dosing-pumps-detail" + }, + { + "vendor": "Agilent (BioTek)", + "name": "EL406", + "capabilities": [ + "plate washing" + ], + "status": "Mostly", + "docs": "/user_guide/00_liquid-handling/plate-washing/biotek-el406.html", + "oem": "https://www.agilent.com/en/product/microplate-instrumentation/microplate-washers-dispensers/biotek-el406-washer-dispenser-795212" + }, + { + "vendor": "Brooks", + "name": "PreciseFlex PF400", + "capabilities": [ + "arm" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/arms/c_scara/precise-flex-pf400/_precise-flex-pf400.html", + "oem": "https://www.brooks.com/laboratory-automation/collaborative-robots/preciseflex-400/" + }, + { + "vendor": "Brooks", + "name": "PreciseFlex PF3400", + "capabilities": [ + "arm" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/arms/c_scara/precise-flex-pf400/_precise-flex-pf400.html", + "oem": "https://www.brooks.com/laboratory-automation/collaborative-robots/preciseflex-400/" + }, + { + "vendor": "PAA", + "name": "KX2", + "capabilities": [ + "arm" + ], + "status": "WIP", + "docs": null, + "oem": null + }, + { + "vendor": "Hamilton", + "name": "HEPA Fan", + "capabilities": [ + "air filtration" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/fans/fans.html", + "oem": null + }, + { + "vendor": "Inheco", + "name": "Thermoshake RM", + "capabilities": [ + "heating", + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/inheco.html", + "oem": "https://www.inheco.com/thermoshake-classic.html" + }, + { + "vendor": "Inheco", + "name": "Thermoshake", + "capabilities": [ + "heating", + "cooling", + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/inheco.html", + "oem": "https://www.inheco.com/thermoshake.html" + }, + { + "vendor": "Inheco", + "name": "Thermoshake AC", + "capabilities": [ + "heating", + "cooling", + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/inheco.html", + "oem": "https://www.inheco.com/thermoshake-ac.html" + }, + { + "vendor": "Opentrons", + "name": "Heater Shaker", + "capabilities": [ + "heating", + "shaking" + ], + "status": "Full", + "docs": null, + "oem": "https://opentrons.com/products/heater-shaker-module" + }, + { + "vendor": "Hamilton", + "name": "Heater Shaker", + "capabilities": [ + "heating", + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/hamilton.html", + "oem": "https://www.hamiltoncompany.com/temperature-control/hamilton-heater-shaker" + }, + { + "vendor": "QInstruments", + "name": "BioShake 3000", + "capabilities": [ + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/qinstruments.html", + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake 3000 elm", + "capabilities": [ + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/qinstruments.html", + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake 3000 elm DWP", + "capabilities": [ + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/qinstruments.html", + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake Q1", + "capabilities": [ + "heating", + "cooling", + "shaking" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/heating_shaking/qinstruments.html", + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake D30 elm", + "capabilities": [ + "shaking" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake 5000 elm", + "capabilities": [ + "shaking" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake 3000-T", + "capabilities": [ + "heating", + "shaking" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake 3000-T elm", + "capabilities": [ + "heating", + "shaking" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake D30-T elm", + "capabilities": [ + "heating", + "shaking" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "BioShake Q2", + "capabilities": [ + "heating", + "cooling", + "shaking" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "Heatplate", + "capabilities": [ + "heating" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "QInstruments", + "name": "ColdPlate", + "capabilities": [ + "heating", + "cooling" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.qinstruments.com/automation/" + }, + { + "vendor": "Thermo Fisher", + "name": "Cytomat 6000", + "capabilities": [ + "heating", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": "https://assets.thermofisher.com/TFS-Assets/CMD/brochures/br-90468-cytomat-2-c-lin-br90468-en.pdf" + }, + { + "vendor": "Thermo Fisher", + "name": "Cytomat 6002", + "capabilities": [ + "heating", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": "https://www.thermofisher.com/order/catalog/product/50075279" + }, + { + "vendor": "Thermo Fisher", + "name": "Cytomat 2 C_50", + "capabilities": [ + "heating", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": null + }, + { + "vendor": "Thermo Fisher", + "name": "Cytomat 2 C425", + "capabilities": [ + "heating", + "cooling", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": "https://www.thermofisher.com/order/catalog/product/51033032" + }, + { + "vendor": "Thermo Fisher", + "name": "Cytomat 2 C450 Shake", + "capabilities": [ + "heating", + "shaking", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": "https://www.thermofisher.com/order/catalog/product/51033035" + }, + { + "vendor": "Thermo Fisher", + "name": "Cytomat 5C", + "capabilities": [ + "heating", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": "https://www.thermofisher.com/order/catalog/product/51031526" + }, + { + "vendor": "Thermo/Liconic", + "name": "Heraeus Cytomat", + "capabilities": [ + "heating", + "storage" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/incubators/cytomat.html", + "oem": null + }, + { + "vendor": "Inheco", + "name": "Incubator Shaker", + "capabilities": [ + "heating", + "shaking", + "storage" + ], + "status": "Mostly", + "docs": null, + "oem": "https://www.inheco.com/incubator-shaker.html" + }, + { + "vendor": "Inheco", + "name": "SCILA", + "capabilities": [ + "heating", + "shaking", + "storage" + ], + "status": "Mostly", + "docs": null, + "oem": "https://www.inheco.com/scila.html" + }, + { + "vendor": "Azenta", + "name": "XPeel", + "capabilities": [ + "peeling" + ], + "status": "Full", + "docs": null, + "oem": "https://www.azenta.com/products/automated-plate-seal-remover-formerly-xpeel" + }, + { + "vendor": "Azenta", + "name": "a4S", + "capabilities": [ + "sealing" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/sealers/a4s.html", + "oem": "https://www.azenta.com/products/automated-roll-heat-sealer-formerly-a4s" + }, + { + "vendor": "Opentrons", + "name": "Thermocycler", + "capabilities": [ + "heating", + "cooling" + ], + "status": "Full", + "docs": null, + "oem": "https://opentrons.com/products/thermocycler-module-1" + }, + { + "vendor": "Thermo Fisher", + "name": "ATC", + "capabilities": [ + "heating", + "cooling" + ], + "status": "Full", + "docs": null, + "oem": "https://www.thermofisher.com/us/en/home/life-science/pcr/thermal-cyclers-realtime-instruments/thermal-cyclers/automated-thermal-cycler-atc.html" + }, + { + "vendor": "Thermo Fisher", + "name": "ProFlex", + "capabilities": [ + "heating", + "cooling" + ], + "status": "Full", + "docs": null, + "oem": "https://www.thermofisher.com/us/en/home/life-science/pcr/thermal-cyclers-realtime-instruments/thermal-cyclers/proflex-pcr-system.html" + }, + { + "vendor": "Inheco", + "name": "ODTC", + "capabilities": [ + "heating", + "cooling" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.inheco.com/odtc.html" + }, + { + "vendor": "Opentrons", + "name": "Temperature Module", + "capabilities": [ + "heating", + "cooling" + ], + "status": "Mostly", + "docs": "/user_guide/01_material-handling/temperature.html", + "oem": "https://opentrons.com/products/temperature-module-gen2" + }, + { + "vendor": "Inheco", + "name": "CPAC", + "capabilities": [ + "heating", + "cooling" + ], + "status": "Full", + "docs": null, + "oem": "https://www.inheco.com/cpac.html" + }, + { + "vendor": "Hamilton", + "name": "Tilt Module", + "capabilities": [ + "tilting" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/tilting.html", + "oem": "https://www.hamiltoncompany.com/other-robotics/188061" + }, + { + "vendor": "Agilent", + "name": "VSpin", + "capabilities": [ + "centrifuging" + ], + "status": "Mostly", + "docs": "/user_guide/01_material-handling/centrifuge/agilent_vspin.html", + "oem": "https://www.agilent.com/en/product/automated-liquid-handling/automated-microplate-management/microplate-centrifuge" + }, + { + "vendor": "Agilent", + "name": "VSpin Access2 Loader", + "capabilities": [ + "centrifuging" + ], + "status": "Full", + "docs": "/user_guide/01_material-handling/centrifuge/agilent_vspin.html#loader", + "oem": "https://www.agilent.com/en/product/automated-liquid-handling/automated-microplate-management/microplate-centrifuge" + }, + { + "vendor": "BMG Labtech", + "name": "CLARIOstar Plus", + "capabilities": [ + "absorbance", + "fluorescence", + "luminescence" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/bmg-clariostar.html", + "oem": "https://www.bmglabtech.com/en/clariostar-plus/" + }, + { + "vendor": "Agilent (BioTek)", + "name": "Cytation 1", + "capabilities": [ + "microscopy" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/cytation.html", + "oem": "https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-1-cell-imaging-multimode-reader-1623200" + }, + { + "vendor": "Agilent (BioTek)", + "name": "Cytation 5", + "capabilities": [ + "absorbance", + "fluorescence", + "luminescence", + "microscopy" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/cytation.html", + "oem": "https://www.agilent.com/en/product/cell-analysis/cell-imaging-microscopy/cell-imaging-multimode-readers/biotek-cytation-5-cell-imaging-multimode-reader-1623202" + }, + { + "vendor": "Agilent (BioTek)", + "name": "Synergy H1", + "capabilities": [ + "absorbance", + "fluorescence", + "luminescence" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/synergyh1.html", + "oem": "https://www.agilent.com/en/product/microplate-instrumentation/microplate-readers/multimode-microplate-readers/biotek-synergy-h1-multimode-reader-1623193" + }, + { + "vendor": "Byonoy", + "name": "Absorbance 96 Automate", + "capabilities": [ + "absorbance" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/byonoy/absorbance.html", + "oem": "https://byonoy.com/absorbance-96-automate/" + }, + { + "vendor": "Byonoy", + "name": "Luminescence 96", + "capabilities": [ + "luminescence" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/byonoy/luminescence.html", + "oem": "https://byonoy.com/luminescence-96/" + }, + { + "vendor": "Byonoy", + "name": "Luminescence 96 Automate", + "capabilities": [ + "luminescence" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/plate-reading/byonoy/luminescence.html", + "oem": "https://byonoy.com/luminescence-96-automate/" + }, + { + "vendor": "Molecular Devices", + "name": "SpectraMax M5e", + "capabilities": [ + "absorbance", + "fluorescence" + ], + "status": "Full", + "docs": null, + "oem": "https://www.moleculardevices.com/products/microplate-readers/multi-mode-readers/spectramax-m-series-readers" + }, + { + "vendor": "Molecular Devices", + "name": "SpectraMax 384plus", + "capabilities": [ + "absorbance" + ], + "status": "Full", + "docs": null, + "oem": "https://www.moleculardevices.com/products/microplate-readers/absorbance-readers/spectramax-abs-plate-readers" + }, + { + "vendor": "Molecular Devices", + "name": "ImageXpress Pico", + "capabilities": [ + "microscopy" + ], + "status": "Basic", + "docs": "/user_guide/02_analytical/plate-reading/pico.html", + "oem": "https://www.moleculardevices.com/products/cellular-imaging-systems/high-content-imaging/imagexpress-pico" + }, + { + "vendor": "Tecan", + "name": "Infinite 200 PRO", + "capabilities": [ + "absorbance", + "fluorescence", + "luminescence" + ], + "status": "Mostly", + "docs": "/user_guide/02_analytical/plate-reading/tecan-infinite.html", + "oem": "https://lifesciences.tecan.com/infinite-200-pro" + }, + { + "vendor": "Beckman Coulter", + "name": "CytoFLEX S", + "capabilities": [ + "flow cytometry" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.beckman.com/flow-cytometry/research-flow-cytometers/cytoflex-s" + }, + { + "vendor": "Thermo Fisher", + "name": "QuantStudio 5", + "capabilities": [ + "qPCR" + ], + "status": "WIP", + "docs": null, + "oem": "https://www.thermofisher.com/order/catalog/product/A34322" + }, + { + "vendor": "Mettler Toledo", + "name": "WXS205SDU", + "capabilities": [ + "weighing" + ], + "status": "Full", + "docs": "/user_guide/02_analytical/scales.html#mettler-toledo-wxs205sdu", + "oem": "https://www.mt.com/us/en/home/products/Industrial_Weighing_Solutions/high-precision-weigh-sensors/weigh-module-wxs205sdu-15-11121008.html" + } +] \ No newline at end of file diff --git a/docs/api/pylabrobot.arms.rst b/docs/api/pylabrobot.arms.rst new file mode 100644 index 00000000000..60a7050203d --- /dev/null +++ b/docs/api/pylabrobot.arms.rst @@ -0,0 +1,42 @@ +.. currentmodule:: pylabrobot.arms + +pylabrobot.arms package +======================= + +Arm capabilities for picking up, moving, and placing labware. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + arm.GripperArm + orientable_arm.OrientableArm + + +Backends +-------- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + backend.GripperArmBackend + backend.OrientableGripperArmBackend + backend.ArticulatedGripperArmBackend + backend.CanFreedrive + backend.HasJoints + backend.CanGrip + + +Types +----- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + standard.GripperLocation + standard.GripDirection diff --git a/docs/api/pylabrobot.capabilities.rst b/docs/api/pylabrobot.capabilities.rst new file mode 100644 index 00000000000..14ddb6a4197 --- /dev/null +++ b/docs/api/pylabrobot.capabilities.rst @@ -0,0 +1,268 @@ +.. currentmodule:: pylabrobot.capabilities + +pylabrobot.capabilities package +=============================== + +Base classes for the capability system. + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + capability.Capability + capability.CapabilityBackend + capability.BackendParams + + +Temperature Control +------------------- + +.. currentmodule:: pylabrobot.capabilities.temperature_controlling.temperature_controller + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + TemperatureController + TemperatureControllerBackend + + +Shaking +------- + +.. currentmodule:: pylabrobot.capabilities.shaking.shaking + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Shaker + ShakerBackend + + +Fan Control +----------- + +.. currentmodule:: pylabrobot.capabilities.fan_control.fan_control + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Fan + FanBackend + + +Humidity Control +---------------- + +.. currentmodule:: pylabrobot.capabilities.humidity_controlling.humidity_controller + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + HumidityController + HumidityControllerBackend + + +Centrifuging +------------ + +.. currentmodule:: pylabrobot.capabilities.centrifuging.centrifuging + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Centrifuge + CentrifugeBackend + + +Sealing +------- + +.. currentmodule:: pylabrobot.capabilities.sealing.sealing + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Sealer + SealerBackend + + +Peeling +------- + +.. currentmodule:: pylabrobot.capabilities.peeling.peeling + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Peeler + PeelerBackend + + +Tilting +------- + +.. currentmodule:: pylabrobot.capabilities.tilting.tilting + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Tilter + TilterBackend + + +Pumping +------- + +.. currentmodule:: pylabrobot.capabilities.pumping.pumping + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Pump + PumpBackend + PumpCalibration + + +Weighing +-------- + +.. currentmodule:: pylabrobot.capabilities.weighing.weighing + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Scale + ScaleBackend + + +Barcode Scanning +---------------- + +.. currentmodule:: pylabrobot.capabilities.barcode_scanning.barcode_scanning + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BarcodeScanner + BarcodeScannerBackend + + +Microscopy +---------- + +.. currentmodule:: pylabrobot.capabilities.microscopy.microscopy + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Microscopy + MicroscopyBackend + + +Automated Retrieval +------------------- + +.. currentmodule:: pylabrobot.capabilities.automated_retrieval.automated_retrieval + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + AutomatedRetrieval + AutomatedRetrievalBackend + + +Plate Reading - Absorbance +-------------------------- + +.. currentmodule:: pylabrobot.capabilities.plate_reading.absorbance.absorbance + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Absorbance + AbsorbanceBackend + + +Plate Reading - Fluorescence +----------------------------- + +.. currentmodule:: pylabrobot.capabilities.plate_reading.fluorescence.fluorescence + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Fluorescence + FluorescenceBackend + + +Plate Reading - Luminescence +----------------------------- + +.. currentmodule:: pylabrobot.capabilities.plate_reading.luminescence.luminescence + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Luminescence + LuminescenceBackend + + +Liquid Handling - PIP (Independent Channels) +-------------------------------------------- + +.. currentmodule:: pylabrobot.capabilities.liquid_handling.pip + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PIP + PIPBackend + + +Liquid Handling - Head96 (96-Channel Head) +------------------------------------------ + +.. currentmodule:: pylabrobot.capabilities.liquid_handling.head96 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Head96 + Head96Backend diff --git a/docs/api/pylabrobot.centrifuge.rst b/docs/api/pylabrobot.centrifuge.rst deleted file mode 100644 index df0932e8e22..00000000000 --- a/docs/api/pylabrobot.centrifuge.rst +++ /dev/null @@ -1,24 +0,0 @@ -.. currentmodule:: pylabrobot.centrifuge - -pylabrobot.centrifuge package -================================ - -This package contains APIs for working with centrifuges. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - centrifuge.Centrifuge - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - vspin_backend.VSpinBackend diff --git a/docs/api/pylabrobot.heating_shaking.rst b/docs/api/pylabrobot.heating_shaking.rst deleted file mode 100644 index e5f7427f5d2..00000000000 --- a/docs/api/pylabrobot.heating_shaking.rst +++ /dev/null @@ -1,28 +0,0 @@ -.. currentmodule:: pylabrobot.heating_shaking - -pylabrobot.heating_shaking package -================================== - -This package contains APIs for working with heater shakers. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - heater_shaker.HeaterShaker - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - chatterbox.HeaterShakerChatterboxBackend - inheco.thermoshake_backend.InhecoThermoshakeBackend - inheco.thermoshake.inheco_thermoshake_ac - inheco.thermoshake.inheco_thermoshake - inheco.thermoshake.inheco_thermoshake_rm diff --git a/docs/api/pylabrobot.io.sila.rst b/docs/api/pylabrobot.io.sila.rst deleted file mode 100644 index d570e08cfdc..00000000000 --- a/docs/api/pylabrobot.io.sila.rst +++ /dev/null @@ -1,17 +0,0 @@ -.. currentmodule:: pylabrobot.io.sila - -pylabrobot.io.sila package -========================== - -This package provides utilities for working with `SiLA `_ instruments. - -Discovery ---------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - discovery.SiLADevice - discovery.discover diff --git a/docs/api/pylabrobot.liquid_handling.backends.rst b/docs/api/pylabrobot.liquid_handling.backends.rst deleted file mode 100644 index 0ac11abd163..00000000000 --- a/docs/api/pylabrobot.liquid_handling.backends.rst +++ /dev/null @@ -1,41 +0,0 @@ -.. currentmodule:: pylabrobot.liquid_handling - -pylabrobot.liquid_handling.backends package -=========================================== - -Backends are used to communicate with liquid handling devices on a low level. Using them directly can be useful when you want to have very low level control over the liquid handling device or want to use a feature that is not yet implemented in the front end. - -Abstract --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backends.backend.LiquidHandlerBackend - backends.serializing_backend.SerializingBackend - -Hardware --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backends.hamilton.base.HamiltonLiquidHandler - backends.hamilton.STAR_backend.STAR - backends.hamilton.vantage_backend.Vantage - backends.opentrons_backend.OpentronsOT2Backend - backends.tecan.EVO_backend.EVOBackend - -Testing -------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backends.chatterbox.LiquidHandlerChatterboxBackend diff --git a/docs/api/pylabrobot.liquid_handling.rst b/docs/api/pylabrobot.liquid_handling.rst deleted file mode 100644 index 75b75b2b02c..00000000000 --- a/docs/api/pylabrobot.liquid_handling.rst +++ /dev/null @@ -1,52 +0,0 @@ -.. currentmodule:: pylabrobot.liquid_handling - -pylabrobot.liquid_handling package -================================== - -This package contains all APIs relevant to liquid handling. -.. See :doc:`Basic liquid handling ` for a simple example. - -Machine control is split into two parts: backends and front ends. Backends are used to control the -machine, and front ends are used to interact with the backend. Front ends are designed to be -largely backend agnostic, and can be used with any backend, meaning programs using this API can -be run on practically all supported hardware. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - liquid_handler.LiquidHandler - - -Backends --------- - -.. toctree:: - :maxdepth: 3 - - pylabrobot.liquid_handling.backends - - -Operations ----------- - -Operations are the main data holders used to transmit information from the liquid handler to a backend. They are the basis of "standard form". - - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - standard - - -Strictness ----------- - -.. toctree:: - :maxdepth: 1 - :caption: Strictness - - pylabrobot.liquid_handling.strictness diff --git a/docs/api/pylabrobot.liquid_handling.strictness.rst b/docs/api/pylabrobot.liquid_handling.strictness.rst deleted file mode 100644 index e454679b093..00000000000 --- a/docs/api/pylabrobot.liquid_handling.strictness.rst +++ /dev/null @@ -1,12 +0,0 @@ -pylabrobot.liquid_handling.strictness package -============================================= - -This package handles the strictness of robot specific functionality being used in an agnostic setting. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pylabrobot.liquid_handling.strictness.Strictness - pylabrobot.liquid_handling.strictness.set_strictness diff --git a/docs/api/pylabrobot.machine.rst b/docs/api/pylabrobot.machine.rst deleted file mode 100644 index fe0d3895da3..00000000000 --- a/docs/api/pylabrobot.machine.rst +++ /dev/null @@ -1,11 +0,0 @@ -.. currentmodule:: pylabrobot.machines - -Machine is a backend'd Resource. Check out the contributing section for more information. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - machine.Machine - backend.MachineBackend diff --git a/docs/api/pylabrobot.only_fans.rst b/docs/api/pylabrobot.only_fans.rst deleted file mode 100644 index 177482e48b5..00000000000 --- a/docs/api/pylabrobot.only_fans.rst +++ /dev/null @@ -1,26 +0,0 @@ -.. currentmodule:: pylabrobot.only_fans - -pylabrobot.only_fans package -================================ - -This package contains APIs just for fans. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - fan.Fan - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backend.FanBackend - chatterbox.FanChatterboxBackend - hamilton_hepa_fan_backend.HamiltonHepaFanBackend diff --git a/docs/api/pylabrobot.plate_reading.rst b/docs/api/pylabrobot.plate_reading.rst deleted file mode 100644 index cde365b0fbc..00000000000 --- a/docs/api/pylabrobot.plate_reading.rst +++ /dev/null @@ -1,29 +0,0 @@ -.. currentmodule:: pylabrobot.plate_reading - -pylabrobot.plate_reading package -================================ - -This package contains APIs for working with plate readers. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - plate_reader.PlateReader - imager.Imager - standard.ImagingResult - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - chatterbox.PlateReaderChatterboxBackend - bmg_labtech.clario_star_backend.CLARIOstarBackend - agilent.biotek_cytation_backend.CytationBackend - agilent.biotek_synergyh1_backend.SynergyH1Backend diff --git a/docs/api/pylabrobot.pumps.rst b/docs/api/pylabrobot.pumps.rst deleted file mode 100644 index f59194b3014..00000000000 --- a/docs/api/pylabrobot.pumps.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. currentmodule:: pylabrobot.pumps - -pylabrobot.pumps package -======================== - -This package contains APIs for working with pumps. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - pump.Pump - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - chatterbox.PumpChatterboxBackend - cole_parmer.masterflex_backend.MasterflexBackend diff --git a/docs/api/pylabrobot.resources.rst b/docs/api/pylabrobot.resources.rst index 5cbc3ceee2e..ccd5d0a3ee0 100644 --- a/docs/api/pylabrobot.resources.rst +++ b/docs/api/pylabrobot.resources.rst @@ -10,6 +10,7 @@ Resources represent on-deck liquid handling equipment, including tip racks, plat :nosignatures: :recursive: + barcode.Barcode Carrier Container Coordinate @@ -29,6 +30,8 @@ Resources represent on-deck liquid handling equipment, including tip racks, plat tip.Tip TipCarrier TipRack + tip_rack.TipSpot + Trash Trough Tube TubeCarrier diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 051a61e8bd1..866bc6a0baa 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -3,25 +3,25 @@ API === +Core +---- + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + device.Device + + Subpackages ----------- .. toctree:: :maxdepth: 1 + pylabrobot.capabilities + pylabrobot.arms pylabrobot.config - pylabrobot.centrifuge - pylabrobot.machine - pylabrobot.heating_shaking - pylabrobot.liquid_handling - pylabrobot.plate_reading - pylabrobot.pumps - pylabrobot.only_fans pylabrobot.resources - pylabrobot.scales - pylabrobot.io.sila - pylabrobot.shaking - pylabrobot.temperature_controlling - pylabrobot.thermocycling - pylabrobot.tilting pylabrobot.utils diff --git a/docs/api/pylabrobot.scales.rst b/docs/api/pylabrobot.scales.rst deleted file mode 100644 index 2b8346e56ee..00000000000 --- a/docs/api/pylabrobot.scales.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. currentmodule:: pylabrobot.scales - -pylabrobot.scales package -========================= - -This package contains APIs for working with scales. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - scale.Scale - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - chatterbox.ScaleChatterboxBackend - mettler_toledo_backend.MettlerToledoWXS205SDU diff --git a/docs/api/pylabrobot.shaking.rst b/docs/api/pylabrobot.shaking.rst deleted file mode 100644 index 66f4570ad1a..00000000000 --- a/docs/api/pylabrobot.shaking.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. currentmodule:: pylabrobot.shaking - -pylabrobot.shaking package -========================== - -This package contains APIs for working with shakers. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - shaker.Shaker - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backend.ShakerBackend - chatterbox.ShakerChatterboxBackend diff --git a/docs/api/pylabrobot.temperature_controlling.rst b/docs/api/pylabrobot.temperature_controlling.rst deleted file mode 100644 index 57879284985..00000000000 --- a/docs/api/pylabrobot.temperature_controlling.rst +++ /dev/null @@ -1,29 +0,0 @@ -.. currentmodule:: pylabrobot.temperature_controlling - -pylabrobot.temperature_controlling package -========================================== - -This package contains APIs for working with temperature controllers (heaters and coolers). - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - temperature_controller.TemperatureController - opentrons.OpentronsTemperatureModuleV2 - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - chatterbox.TemperatureControllerChatterboxBackend - opentrons_backend.OpentronsTemperatureModuleBackend - inheco.control_box.InhecoTECControlBox - inheco.cpac_backend.InhecoCPACBackend - inheco.cpac.inheco_cpac_ultraflat diff --git a/docs/api/pylabrobot.thermocycling.rst b/docs/api/pylabrobot.thermocycling.rst deleted file mode 100644 index d89929abec7..00000000000 --- a/docs/api/pylabrobot.thermocycling.rst +++ /dev/null @@ -1,28 +0,0 @@ -.. currentmodule:: pylabrobot.thermocycling - -pylabrobot.thermocycling package -================================ - -This package contains APIs for working with thermocyclers. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - thermocycler.Thermocycler - opentrons.OpentronsThermocyclerModuleV1 - opentrons.OpentronsThermocyclerModuleV2 - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - backend.ThermocyclerBackend - chatterbox.ThermocyclerChatterboxBackend - opentrons_backend.OpentronsThermocyclerBackend diff --git a/docs/api/pylabrobot.tilting.rst b/docs/api/pylabrobot.tilting.rst deleted file mode 100644 index d69dd256301..00000000000 --- a/docs/api/pylabrobot.tilting.rst +++ /dev/null @@ -1,27 +0,0 @@ -.. currentmodule:: pylabrobot.tilting - -pylabrobot.tilting package -========================== - -This package contains APIs for working with tilt modules. - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - tilter.Tilter - - -Backends --------- - -.. autosummary:: - :toctree: _autosummary - :nosignatures: - :recursive: - - chatterbox.TilterChatterboxBackend - tilter_backend.TilterBackend - hamilton_backend.HamiltonTiltModuleDriver - hamilton_backend.HamiltonTiltModuleTilterBackend diff --git a/docs/conf.py b/docs/conf.py index 5e4758239d3..d5676a7e9b2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,6 +47,7 @@ "IPython.sphinxext.ipython_console_highlighting", "sphinx_reredirects", "sphinx_sitemap", + "plr_devices", ] intersphinx_mapping = { diff --git a/docs/user_guide/capabilities/absorbance.ipynb b/docs/user_guide/capabilities/absorbance.ipynb new file mode 100644 index 00000000000..fa2172aa4b0 --- /dev/null +++ b/docs/user_guide/capabilities/absorbance.ipynb @@ -0,0 +1,84 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Absorbance\n", + "\n", + "{class}`~pylabrobot.capabilities.plate_reading.absorbance.absorbance.Absorbance` reads absorbance (optical density) from microplates.\n", + "\n", + "## When to use\n", + "\n", + "Use this for OD measurements (cell density, ELISA readouts, protein quantification, etc.).\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.plate_reading.absorbance import Absorbance\n", + "from pylabrobot.capabilities.plate_reading.absorbance.chatterbox import AbsorbanceChatterboxBackend\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "absorbance = Absorbance(backend=AbsorbanceChatterboxBackend())\n", + "await absorbance._on_setup()\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Read all wells at 450 nm\n", + "results = await absorbance.read(plate=plate, wavelength=450)\n", + "print(f\"A1 OD450: {results[0].data[0][0]}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Read specific wells\n", + "wells = [plate.get_well(\"A1\"), plate.get_well(\"B1\")]\n", + "results = await absorbance.read(plate=plate, wavelength=600, wells=wells)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} absorbance\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.plate_reading.absorbance.absorbance.Absorbance` and {class}`~pylabrobot.capabilities.plate_reading.absorbance.absorbance.AbsorbanceBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/arms.md b/docs/user_guide/capabilities/arms.md new file mode 100644 index 00000000000..2f4ebcd332c --- /dev/null +++ b/docs/user_guide/capabilities/arms.md @@ -0,0 +1,77 @@ +# Arms + +Arms are capabilities for picking up, moving, and placing labware (plates, lids, etc.) on the deck. PLR provides two arm types: + +- {class}`~pylabrobot.arms.arm.GripperArm` -- a fixed-axis gripper arm (e.g. Hamilton core grippers). Grips along a single axis. +- {class}`~pylabrobot.arms.orientable_arm.OrientableArm` -- a rotatable gripper arm (e.g. Hamilton iSWAP). Can grip from any direction. + +Both inherit from `_BaseArm`, which is a {class}`~pylabrobot.capabilities.capability.Capability`. + +## When to use + +Use arms to move plates, lids, and other labware between deck positions -- from a hotel to a reader, from a reader to a shaker, from a shaker to a centrifuge, etc. + +## Setup + +Arms are accessed as an attribute on a liquid handler or standalone arm device: + +```python +from pylabrobot.hamilton.star import STAR + +lh = STAR(name="star", ...) +await lh.setup() + +# the arm is at lh.iswap (OrientableArm) or lh.core_gripper (GripperArm) +await lh.iswap.move_resource(plate, to=heater_shaker) +``` + +## Walkthrough + +### Move a plate (one call) + +```python +await lh.iswap.move_resource(plate, to=heater_shaker) +``` + +### Move a plate (step by step) + +```python +await lh.iswap.pick_up_resource(plate) +await lh.iswap.drop_resource(heater_shaker) +``` + +### Move with intermediate waypoints + +```python +await lh.iswap.move_resource( + plate, + to=centrifuge_bucket, + intermediate_locations=[safe_height], # absolute coordinates +) +``` + +### OrientableArm: grip direction + +```python +from pylabrobot.arms.standard import GripDirection + +await lh.iswap.pick_up_resource(plate, direction=GripDirection.LEFT) +await lh.iswap.drop_resource(reader, direction=GripDirection.FRONT) +``` + +## Tips and gotchas + +- **Coordinates are in the reference resource's frame** (typically the deck). The arm computes gripper target coordinates from the resource's position, dimensions, and the destination type. +- **`pickup_distance_from_top`** controls how far down from the top face the gripper grips. If `None`, the resource's `preferred_pickup_location` is used, or a default of 5 mm. +- **Resource tree is updated automatically.** After a successful `drop_resource`, the resource is unassigned from its old parent and assigned to the destination. +- **`GripOrientation`** is either a {class}`~pylabrobot.arms.standard.GripDirection` enum (`FRONT`, `RIGHT`, `BACK`, `LEFT`) or a float in degrees. +- **`request_gripper_location()`** queries the hardware for the current end effector position. `get_picked_up_resource()` returns the internally tracked state (no hardware call). + +## Supported hardware + +```{supported-devices} arm +``` + +## API reference + +See {class}`~pylabrobot.arms.arm.GripperArm`, {class}`~pylabrobot.arms.orientable_arm.OrientableArm`, {class}`~pylabrobot.arms.backend.GripperArmBackend`, and {class}`~pylabrobot.arms.backend.OrientableGripperArmBackend`. diff --git a/docs/user_guide/capabilities/automated-retrieval.ipynb b/docs/user_guide/capabilities/automated-retrieval.ipynb new file mode 100644 index 00000000000..698abc9eefb --- /dev/null +++ b/docs/user_guide/capabilities/automated-retrieval.ipynb @@ -0,0 +1,71 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Automated Retrieval\n", + "\n", + "{class}`~pylabrobot.capabilities.automated_retrieval.automated_retrieval.AutomatedRetrieval` controls automated storage systems (carousels, plate hotels, incubators with internal storage) that can fetch and store plates.\n", + "\n", + "## When to use\n", + "\n", + "Use this to programmatically retrieve plates from storage for processing and return them when done.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval\n", + "from pylabrobot.capabilities.automated_retrieval.chatterbox import AutomatedRetrievalChatterboxBackend\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "retrieval = AutomatedRetrieval(backend=AutomatedRetrievalChatterboxBackend())\n", + "await retrieval._on_setup()\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"my_plate\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await retrieval.fetch_plate_to_loading_tray(plate)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} storage\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.automated_retrieval.automated_retrieval.AutomatedRetrieval` and {class}`~pylabrobot.capabilities.automated_retrieval.automated_retrieval.AutomatedRetrievalBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/barcode-scanning.ipynb b/docs/user_guide/capabilities/barcode-scanning.ipynb new file mode 100644 index 00000000000..cdba713d33a --- /dev/null +++ b/docs/user_guide/capabilities/barcode-scanning.ipynb @@ -0,0 +1,69 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Barcode Scanning\n", + "\n", + "{class}`~pylabrobot.capabilities.barcode_scanning.barcode_scanning.BarcodeScanner` reads barcodes from labware.\n", + "\n", + "## When to use\n", + "\n", + "Use this to identify plates, tubes, or other labware by their barcode during automated workflows -- for tracking, verification, or LIMS integration.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.barcode_scanning import BarcodeScanner\n", + "from pylabrobot.capabilities.barcode_scanning.chatterbox import BarcodeScannerChatterboxBackend\n", + "\n", + "scanner = BarcodeScanner(backend=BarcodeScannerChatterboxBackend())\n", + "await scanner._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "barcode = await scanner.scan()\n", + "print(f\"Scanned: {barcode}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} barcode scanning\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.barcode_scanning.barcode_scanning.BarcodeScanner` and {class}`~pylabrobot.capabilities.barcode_scanning.barcode_scanning.BarcodeScannerBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/centrifuging.ipynb b/docs/user_guide/capabilities/centrifuging.ipynb new file mode 100644 index 00000000000..dda1ce5df3b --- /dev/null +++ b/docs/user_guide/capabilities/centrifuging.ipynb @@ -0,0 +1,111 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Centrifuging\n", + "\n", + "{class}`~pylabrobot.capabilities.centrifuging.centrifuging.Centrifuge` controls automated centrifuges with door, bucket, and spin management.\n", + "\n", + "## When to use\n", + "\n", + "Use this for pelleting cells, separating phases, or any protocol step that requires centrifugal force.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.centrifuging import Centrifuge\n", + "from pylabrobot.capabilities.centrifuging.chatterbox import CentrifugeChatterboxBackend\n", + "from pylabrobot.resources import ResourceHolder\n", + "\n", + "bucket1 = ResourceHolder(name=\"bucket1\", size_x=100, size_y=100, size_z=50)\n", + "bucket2 = ResourceHolder(name=\"bucket2\", size_x=100, size_y=100, size_z=50)\n", + "\n", + "centrifuge = Centrifuge(\n", + " backend=CentrifugeChatterboxBackend(),\n", + " buckets=(bucket1, bucket2),\n", + ")\n", + "await centrifuge._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await centrifuge.open_door()\n", + "# ... load plate into bucket via arm ...\n", + "await centrifuge.close_door()\n", + "await centrifuge.lock_door()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Spin at 500 x g for 2 seconds (blocks until complete)\n", + "await centrifuge.spin(g=500, duration=2)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await centrifuge.unlock_door()\n", + "await centrifuge.open_door()\n", + "\n", + "# Bucket position is unknown after spinning\n", + "print(f\"at_bucket: {centrifuge.at_bucket}\")\n", + "\n", + "# Rotate to a known position before unloading\n", + "await centrifuge.go_to_bucket1()\n", + "print(f\"at_bucket: {centrifuge.at_bucket}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tips and gotchas\n", + "\n", + "- **`spin()` blocks** for the entire cycle (ramp up + time at speed + ramp down).\n", + "- **`duration` is time at speed**, excluding ramp-up and ramp-down.\n", + "- **Bucket position is unknown after spinning.** `at_bucket` resets to `None` after `spin()`.\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} centrifuging\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.centrifuging.centrifuging.Centrifuge` and {class}`~pylabrobot.capabilities.centrifuging.centrifuging.CentrifugeBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/fan-control.ipynb b/docs/user_guide/capabilities/fan-control.ipynb new file mode 100644 index 00000000000..773a1f7b83a --- /dev/null +++ b/docs/user_guide/capabilities/fan-control.ipynb @@ -0,0 +1,91 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Fans (Air Filtration Systems)\n", + "\n", + "{class}`~pylabrobot.capabilities.fan_control.fan_control.Fan` controls air filtration units that condition air within or around the deck to protect samples from contamination.\n", + "\n", + "Their main purpose is to maintain a clean environment for experiments by ensuring consistent airflow and particle removal, reducing risks from dust, aerosols, and microorganisms.\n", + "These systems are not primarily designed for operator safety; separate equipment like fume extractors or biosafety cabinets serves that role.\n", + "\n", + "Common filter technologies include:\n", + "\n", + "- **HEPA filters**: Capture \u226599.97% of airborne particles \u22650.3 \u00b5m, widely used to keep samples clean.\n", + "- **ULPA filters**: Capture even smaller particles for higher-level cleanroom requirements.\n", + "- **Activated carbon filters**: Remove volatile organic compounds (VOCs) and chemical fumes.\n", + "- **Prefilters**: Trap larger particles to extend the lifespan of HEPA/ULPA filters.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.fan_control import Fan\n", + "from pylabrobot.capabilities.fan_control.chatterbox import FanChatterboxBackend\n", + "\n", + "fan = Fan(backend=FanChatterboxBackend())\n", + "await fan._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Run at 80% (returns immediately)\n", + "await fan.turn_on(intensity=80)\n", + "await fan.turn_off()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Run for a fixed duration (blocks)\n", + "await fan.turn_on(intensity=100, duration=2)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tips and gotchas\n", + "\n", + "- **Intensity is 0--100** (percent).\n", + "- **With `duration`: blocks. Without: returns immediately.**\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} air filtration\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.fan_control.fan_control.Fan` and {class}`~pylabrobot.capabilities.fan_control.fan_control.FanBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/fluorescence.ipynb b/docs/user_guide/capabilities/fluorescence.ipynb new file mode 100644 index 00000000000..85d7828d498 --- /dev/null +++ b/docs/user_guide/capabilities/fluorescence.ipynb @@ -0,0 +1,77 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Fluorescence\n", + "\n", + "{class}`~pylabrobot.capabilities.plate_reading.fluorescence.fluorescence.Fluorescence` reads fluorescence intensity from microplates.\n", + "\n", + "## When to use\n", + "\n", + "Use this for fluorescence-based assays (GFP expression, DNA quantification with intercalating dyes, fluorescent ELISAs, etc.).\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence\n", + "from pylabrobot.capabilities.plate_reading.fluorescence.chatterbox import FluorescenceChatterboxBackend\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "fluorescence = Fluorescence(backend=FluorescenceChatterboxBackend())\n", + "await fluorescence._on_setup()\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "results = await fluorescence.read(\n", + " plate=plate,\n", + " excitation_wavelength=485,\n", + " emission_wavelength=528,\n", + " focal_height=8.5,\n", + ")\n", + "print(f\"A1 fluorescence: {results[0].data[0][0]} RFU\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} fluorescence\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.plate_reading.fluorescence.fluorescence.Fluorescence` and {class}`~pylabrobot.capabilities.plate_reading.fluorescence.fluorescence.FluorescenceBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/head96.md b/docs/user_guide/capabilities/head96.md new file mode 100644 index 00000000000..7e63775ff7e --- /dev/null +++ b/docs/user_guide/capabilities/head96.md @@ -0,0 +1,66 @@ +# Head96 (96-Channel Head) + +{class}`~pylabrobot.capabilities.liquid_handling.head96.Head96` controls a 96-channel pipetting head that operates on full tip racks and plates at once. All 96 channels move together as a single unit. + +## When to use + +Use this for plate-to-plate transfers, plate replication, or any operation where all 96 wells are processed identically. Much faster than using independent channels for full-plate operations. + +## Setup + +```python +from pylabrobot.hamilton.star import STAR + +lh = STAR(name="star", ...) +await lh.setup() + +# 96-head is at lh.head96 +await lh.head96.pick_up_tips(tip_rack) +``` + +## Walkthrough + +### Plate-to-plate transfer + +```python +await lh.head96.pick_up_tips(tip_rack) +await lh.head96.aspirate(source_plate, volume=50) +await lh.head96.dispense(target_plate, volume=50) +await lh.head96.return_tips() +``` + +### Stamp (one-liner plate copy) + +```python +await lh.head96.pick_up_tips(tip_rack) +await lh.head96.stamp(source_plate, target_plate, volume=50) +await lh.head96.discard_tips(trash) +``` + +### Aspirating from a trough + +```python +# All 96 tips dip into the same container +await lh.head96.pick_up_tips(tip_rack) +await lh.head96.aspirate(trough, volume=200) +await lh.head96.dispense(plate, volume=200) +await lh.head96.discard_tips(trash) +``` + +## Tips and gotchas + +- **Volumes are in uL, flow rates in uL/s, heights in mm.** +- **Sparse pickup is supported.** If a tip rack is partially empty, only the positions that have tips are picked up. +- **Trough minimum size.** When aspirating from a single container, it must be at least ~101 mm x ~65 mm to accommodate the 96-head geometry (9 mm tip spacing). +- **`stamp` requires same-shape plates.** Both plates must have the same `num_items_x` and `num_items_y`. +- **`return_tips` requires all tips from the same rack.** Raises `RuntimeError` if mounted tips originated from different racks. +- **A `default_offset`** (set at construction) is added to all operation offsets. + +## Supported hardware + +```{supported-devices} liquid handling +``` + +## API reference + +See {class}`~pylabrobot.capabilities.liquid_handling.head96.Head96` and {class}`~pylabrobot.capabilities.liquid_handling.head96.Head96Backend`. diff --git a/docs/user_guide/capabilities/humidity-control.ipynb b/docs/user_guide/capabilities/humidity-control.ipynb new file mode 100644 index 00000000000..75aeeec8eaa --- /dev/null +++ b/docs/user_guide/capabilities/humidity-control.ipynb @@ -0,0 +1,71 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Humidity Control\n", + "\n", + "{class}`~pylabrobot.capabilities.humidity_controlling.humidity_controller.HumidityController` reads and sets relative humidity for incubators and environmental chambers.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.humidity_controlling import HumidityController\n", + "from pylabrobot.capabilities.humidity_controlling.chatterbox import HumidityControllerChatterboxBackend\n", + "\n", + "humid = HumidityController(backend=HumidityControllerChatterboxBackend())\n", + "await humid._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await humid.set_humidity(0.95) # 95% RH\n", + "current = await humid.request_humidity()\n", + "print(f\"Humidity: {current * 100:.1f}%\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tips and gotchas\n", + "\n", + "- **Humidity is a fraction (0.0--1.0)**, not a percentage.\n", + "- **Some backends are read-only.** If `supports_humidity_control` is `False`, calling `set_humidity` raises `ValueError`.\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} humidity\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.humidity_controlling.humidity_controller.HumidityController` and {class}`~pylabrobot.capabilities.humidity_controlling.humidity_controller.HumidityControllerBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/index.md b/docs/user_guide/capabilities/index.md new file mode 100644 index 00000000000..fc24248c652 --- /dev/null +++ b/docs/user_guide/capabilities/index.md @@ -0,0 +1,65 @@ +# Capabilities + +Capabilities are the building blocks of device functionality in PyLabRobot. Each capability defines a standard interface for a specific type of lab operation (e.g. temperature control, shaking, plate reading), decoupled from any particular hardware. + +A single device can expose multiple capabilities -- and capabilities can be optional (`None` if the hardware doesn't support it) or even duplicated (e.g. a device with two independent arms). For example, a heater-shaker exposes both a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` and a {class}`~pylabrobot.capabilities.shaking.shaking.Shaker`. + +## Architecture + +``` +Device + ├── driver (hardware communication) + └── capabilities + ├── TemperatureController (backend) + ├── Shaker (backend) + └── ... +``` + +Each capability has two layers: a **frontend** and a **backend**. + +### Frontend (the capability class) + +The frontend is what you interact with as a user. It provides: + +- A **stable, hardware-agnostic API** -- the same `set_temperature(37.0)` call works regardless of whether you're using an Inheco ThermoShake or a Hamilton Heater Cooler. +- **Validation** -- checking that arguments are in range, that the device is ready, that preconditions are met (e.g. you can't `wait_for_temperature` without first setting a target). +- **State tracking** -- keeping track of which tips are mounted, what the current tilt angle is, whether the door is open, etc. +- **Convenience methods** -- higher-level operations like `stamp` (aspirate + dispense) or `transfer` (one-to-many) built on top of the primitive backend calls. + +The frontend is the same across all hardware that supports the capability. + +### Backend (the hardware-specific implementation) + +The backend is what talks to the actual hardware. Each capability defines an abstract backend class (e.g. {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureControllerBackend`, {class}`~pylabrobot.capabilities.shaking.shaking.ShakerBackend`) that specifies the methods a hardware driver must implement. + +Backend methods are lower-level and closer to the wire protocol. For example, where the frontend {class}`~pylabrobot.capabilities.shaking.shaking.Shaker`.`shake(speed, duration)` handles timing and auto-stop, the backend only needs to implement `start_shaking(speed)` and `stop_shaking()`. + +To add support for a new piece of hardware, you implement the backend interface for the capabilities it supports. The frontend takes care of the rest. + +All capability methods are `async` and require the parent device to be set up before use (enforced by the `@need_capability_ready` decorator). + +## Available capabilities + +```{toctree} +:maxdepth: 1 + +temperature-control +shaking +fan-control +humidity-control +centrifuging +sealing +peeling +tilting +pumping +weighing +barcode-scanning +microscopy +automated-retrieval +absorbance +fluorescence +luminescence +pip +head96 +arms +``` diff --git a/docs/user_guide/capabilities/luminescence.ipynb b/docs/user_guide/capabilities/luminescence.ipynb new file mode 100644 index 00000000000..a869333d7ca --- /dev/null +++ b/docs/user_guide/capabilities/luminescence.ipynb @@ -0,0 +1,72 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Luminescence\n", + "\n", + "{class}`~pylabrobot.capabilities.plate_reading.luminescence.luminescence.Luminescence` reads luminescence from microplates.\n", + "\n", + "## When to use\n", + "\n", + "Use this for luminescence-based assays (luciferase reporters, ATP quantification, chemiluminescent ELISAs, etc.).\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.plate_reading.luminescence import Luminescence\n", + "from pylabrobot.capabilities.plate_reading.luminescence.chatterbox import LuminescenceChatterboxBackend\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "luminescence = Luminescence(backend=LuminescenceChatterboxBackend())\n", + "await luminescence._on_setup()\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "results = await luminescence.read(plate=plate, focal_height=8.5)\n", + "print(f\"A1 luminescence: {results[0].data[0][0]} RLU\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} luminescence\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.plate_reading.luminescence.luminescence.Luminescence` and {class}`~pylabrobot.capabilities.plate_reading.luminescence.luminescence.LuminescenceBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/microscopy.md b/docs/user_guide/capabilities/microscopy.md new file mode 100644 index 00000000000..fb681a900ec --- /dev/null +++ b/docs/user_guide/capabilities/microscopy.md @@ -0,0 +1,93 @@ +# Microscopy + +{class}`~pylabrobot.capabilities.microscopy.microscopy.Microscopy` controls automated microscopes with support for PLR-driven auto-exposure and auto-focus. + +## When to use + +Use this for imaging cells, colonies, crystals, or any sample in microplates -- with automated focus and exposure optimization. + +## Setup + +```python +from pylabrobot.molecular_devices.imageXpress.pico import Pico + +microscope = Pico(name="pico", ...) +await microscope.setup() +``` + +## Walkthrough + +### Basic capture + +```python +result = await microscope.microscopy.capture( + well=plate.get_well("A1"), + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_10X_PL_FL, + plate=plate, +) +# result.images contains the captured data +# result.exposure_time and result.focal_height report what was used +``` + +### Auto-exposure + +PLR can optimize exposure time via a binary search. You provide a callback that evaluates whether an image is under-, over-, or correctly exposed: + +```python +from pylabrobot.capabilities.microscopy.microscopy import AutoExposure, max_pixel_at_fraction + +result = await microscope.microscopy.capture( + well=plate.get_well("A1"), + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_10X_PL_FL, + plate=plate, + exposure_time=AutoExposure( + evaluate_exposure=max_pixel_at_fraction(0.8, margin=0.05), + low=1.0, # min exposure (ms) + high=500.0, # max exposure (ms) + max_rounds=10, + ), + focal_height=5.0, # must be numeric when using AutoExposure + gain=1.0, # must be numeric when using AutoExposure +) +``` + +### Auto-focus + +PLR can optimize focal height via a golden-ratio search: + +```python +from pylabrobot.capabilities.microscopy.microscopy import AutoFocus, evaluate_focus_nvmg_sobel + +result = await microscope.microscopy.capture( + well=(0, 0), # can also pass a (row, col) tuple + mode=ImagingMode.BRIGHTFIELD, + objective=Objective.O_10X_PL_FL, + plate=plate, + exposure_time=50.0, # must be numeric when using AutoFocus + focal_height=AutoFocus( + evaluate_focus=evaluate_focus_nvmg_sobel, + low=0.0, # min focal height (mm) + high=10.0, # max focal height (mm) + tolerance=0.01, # convergence tolerance (mm) + timeout=60, # seconds + ), + gain=1.0, # must be numeric when using AutoFocus +) +``` + +## Tips and gotchas + +- **When using `AutoExposure`, `focal_height` and `gain` must be numeric** (not `"machine-auto"` or `AutoFocus`). Same constraint applies in reverse for `AutoFocus`. +- **`"machine-auto"` defers to the microscope's built-in defaults.** Use this when you don't need PLR-driven optimization. +- **`evaluate_focus_nvmg_sobel`** computes focus quality using a Sobel filter on the center 50% of the image. Higher scores mean sharper focus. + +## Supported hardware + +```{supported-devices} microscopy +``` + +## API reference + +See {class}`~pylabrobot.capabilities.microscopy.microscopy.Microscopy` and {class}`~pylabrobot.capabilities.microscopy.microscopy.MicroscopyBackend`. diff --git a/docs/user_guide/capabilities/peeling.ipynb b/docs/user_guide/capabilities/peeling.ipynb new file mode 100644 index 00000000000..5e3b59e2f55 --- /dev/null +++ b/docs/user_guide/capabilities/peeling.ipynb @@ -0,0 +1,64 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Peeling\n", + "\n", + "{class}`~pylabrobot.capabilities.peeling.peeling.Peeler` controls automated de-seal (peel) cycles for removing plate seals.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.peeling import Peeler\n", + "from pylabrobot.capabilities.peeling.chatterbox import PeelerChatterboxBackend\n", + "\n", + "peeler = Peeler(backend=PeelerChatterboxBackend())\n", + "await peeler._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await peeler.peel()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} peeling\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.peeling.peeling.Peeler` and {class}`~pylabrobot.capabilities.peeling.peeling.PeelerBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/pip.md b/docs/user_guide/capabilities/pip.md new file mode 100644 index 00000000000..34c060db143 --- /dev/null +++ b/docs/user_guide/capabilities/pip.md @@ -0,0 +1,89 @@ +# PIP (Independent Channels) + +{class}`~pylabrobot.capabilities.liquid_handling.pip.PIP` controls independent pipetting channels for tip handling, aspiration, and dispensing. + +## When to use + +Use this for any pipetting operation that uses individual channels: serial dilutions, cherry-picking, reformatting between plates, etc. + +## Setup + +PIP is accessed as an attribute on a liquid handler: + +```python +from pylabrobot.hamilton.star import STAR + +lh = STAR(name="star", ...) +await lh.setup() + +# independent channels are at lh.pip +await lh.pip.pick_up_tips(tip_rack["A1:H1"]) +``` + +## Walkthrough + +### Basic pipetting + +```python +# Pick up 8 tips +await lh.pip.pick_up_tips(tip_rack["A1:H1"]) + +# Aspirate 100 uL from column 1 +await lh.pip.aspirate(plate["A1:H1"], vols=[100] * 8) + +# Dispense 100 uL into column 2 +await lh.pip.dispense(plate["A2:H2"], vols=[100] * 8) + +# Return tips to where they came from +await lh.pip.return_tips() +``` + +### Using specific channels + +```python +# Use only channels 0 and 1 +with lh.pip.use_channels([0, 1]): + await lh.pip.pick_up_tips([tip_rack["A1"], tip_rack["B1"]]) + await lh.pip.aspirate([plate["A1"], plate["B1"]], vols=[50, 50]) + await lh.pip.dispense([plate["A2"], plate["B2"]], vols=[50, 50]) + await lh.pip.drop_tips([tip_rack["A1"], tip_rack["B1"]]) +``` + +### Automatic tip management + +```python +# use_tips picks up on entry, discards on exit +async with lh.pip.use_tips(tip_rack["A1:H1"], trash=trash): + await lh.pip.aspirate(plate["A1:H1"], vols=[100] * 8) + await lh.pip.dispense(plate["A2:H2"], vols=[100] * 8) +# tips are discarded automatically +``` + +### One-to-many transfer + +```python +# Aspirate from one well, distribute to multiple targets +await lh.pip.transfer( + source=plate["A1"], + targets=[plate["B1"], plate["C1"], plate["D1"]], + source_vol=300, # aspirate 300 uL total + ratios=[1, 1, 1], # equal distribution (100 uL each) +) +``` + +## Tips and gotchas + +- **Volumes are in uL, flow rates in uL/s, heights in mm, offsets in mm.** +- **`spread` mode** controls how channels are positioned when aspirating/dispensing from a single container: `"wide"` maximizes spacing, `"tight"` minimizes it, `"custom"` uses your offsets. +- **Tip tracking is transactional.** If a multi-channel operation partially fails, only the channels that succeeded are committed. The rest are rolled back. +- **Volume tracking.** The capability tracks liquid volumes per tip and per well. `allow_nonzero_volume=False` (default on `drop_tips`) prevents you from dropping tips that still have liquid. +- **`discard_tips` defaults to `allow_nonzero_volume=True`**, since discarding tips with residual liquid is common. + +## Supported hardware + +```{supported-devices} liquid handling +``` + +## API reference + +See {class}`~pylabrobot.capabilities.liquid_handling.pip.PIP` and {class}`~pylabrobot.capabilities.liquid_handling.pip.PIPBackend`. diff --git a/docs/user_guide/capabilities/pumping.ipynb b/docs/user_guide/capabilities/pumping.ipynb new file mode 100644 index 00000000000..b7c51196f81 --- /dev/null +++ b/docs/user_guide/capabilities/pumping.ipynb @@ -0,0 +1,100 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Pumping\n", + "\n", + "{class}`~pylabrobot.capabilities.pumping.pumping.Pump` controls peristaltic and syringe pumps for fluid delivery.\n", + "\n", + "## When to use\n", + "\n", + "Use this for bulk liquid delivery, reagent dispensing, or continuous flow applications where pipetting is impractical.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.pumping import Pump\n", + "from pylabrobot.capabilities.pumping.chatterbox import PumpChatterboxBackend\n", + "\n", + "pump = Pump(backend=PumpChatterboxBackend())\n", + "await pump._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Run for a fixed number of revolutions (blocks)\n", + "await pump.run_revolutions(10)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Run continuously (returns immediately), then stop\n", + "await pump.run_continuously(50)\n", + "await pump.halt()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Run for a fixed duration (blocks)\n", + "await pump.run_for_duration(speed=30, duration=2)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Calibration\n", + "\n", + "To use `pump_volume`, the capability needs a {class}`~pylabrobot.capabilities.pumping.calibration.PumpCalibration`. The calibration converts a (speed, volume) pair into either a duration or a number of revolutions.\n", + "\n", + "## Tips and gotchas\n", + "\n", + "- **`run_continuously` returns immediately.** The pump runs until you call `halt()`.\n", + "- **`run_revolutions` and `run_for_duration` block.**\n", + "- **`pump_volume` requires a calibration.** Raises `NotCalibratedError` if none is set.\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} pumping\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.pumping.pumping.Pump` and {class}`~pylabrobot.capabilities.pumping.pumping.PumpBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/sealing.ipynb b/docs/user_guide/capabilities/sealing.ipynb new file mode 100644 index 00000000000..2386439a936 --- /dev/null +++ b/docs/user_guide/capabilities/sealing.ipynb @@ -0,0 +1,90 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Sealing\n", + "\n", + "{class}`~pylabrobot.capabilities.sealing.sealing.Sealer` controls thermal and adhesive plate sealers.\n", + "\n", + "In automated wet lab workflows, microplate sealers are essential for preserving sample integrity.\n", + "They prevent evaporation, cross-contamination, and spillage, especially during heating, shaking, storage, or robotic transport.\n", + "\n", + "## Types of sealers\n", + "\n", + "### Thermal sealers\n", + "\n", + "These use heat and pressure to bond a sealing film (typically foil or heat-reactive plastic) to the top of the microplate.\n", + "\n", + "- **Best for**: Long-term storage, PCR/qPCR workflows, high-integrity applications\n", + "- **Pros**: Very strong seal; compatible with a wide range of films\n", + "- **Cons**: Slower sealing time (typically 5--10 seconds per plate); requires warm-up time; when peeled, thermal seals may remove well material\n", + "\n", + "### Adhesive (pressure) sealers\n", + "\n", + "These apply pre-cut adhesive seals to the plate using downward mechanical pressure.\n", + "They do **not** use heat, making them faster and simpler for certain workflows.\n", + "\n", + "- **Best for**: Medium-throughput workflows, frequent access, short-term incubation\n", + "- **Pros**: Faster (as low as 1--2 seconds per plate); no warm-up period; compatible with repeelable seals\n", + "- **Cons**: Weaker seal compared to thermal; not suitable for long-term storage or high-temperature protocols\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.sealing import Sealer\n", + "from pylabrobot.capabilities.sealing.chatterbox import SealerChatterboxBackend\n", + "\n", + "sealer = Sealer(backend=SealerChatterboxBackend())\n", + "await sealer._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await sealer.open()\n", + "# ... place plate on shuttle via arm ...\n", + "await sealer.close()\n", + "await sealer.seal(temperature=170, duration=3)\n", + "await sealer.open()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} sealing\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.sealing.sealing.Sealer` and {class}`~pylabrobot.capabilities.sealing.sealing.SealerBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/shaking.ipynb b/docs/user_guide/capabilities/shaking.ipynb new file mode 100644 index 00000000000..b7adde36f75 --- /dev/null +++ b/docs/user_guide/capabilities/shaking.ipynb @@ -0,0 +1,117 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Shaking\n", + "\n", + "{class}`~pylabrobot.capabilities.shaking.shaking.Shaker` controls orbital and linear shakers, with optional plate locking.\n", + "\n", + "## When to use\n", + "\n", + "Use this for mixing samples, resuspending pellets, or keeping suspensions homogeneous during incubation.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.shaking import Shaker\n", + "from pylabrobot.capabilities.shaking.chatterbox import ShakerChatterboxBackend\n", + "\n", + "shaker = Shaker(backend=ShakerChatterboxBackend())\n", + "await shaker._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Shake for a fixed duration (blocks for the full 60 seconds):" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await shaker.shake(speed=500, duration=2) # 2 seconds for demo" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Or start shaking indefinitely (returns immediately), then stop manually:" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await shaker.shake(speed=300)\n", + "# ... do other things ...\n", + "await shaker.stop_shaking()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lock/unlock the plate (when the backend supports it):" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await shaker.lock_plate()\n", + "await shaker.shake(speed=1000, duration=1)\n", + "await shaker.unlock_plate()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tips and gotchas\n", + "\n", + "- **With `duration`: blocks. Without: returns immediately.**\n", + "- **Auto-locking.** When the backend supports it, `shake()` automatically locks before shaking and unlocks after (when `duration` is set).\n", + "- **Speed is in RPM.**\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} shaking\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.shaking.shaking.Shaker` and {class}`~pylabrobot.capabilities.shaking.shaking.ShakerBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/temperature-control.ipynb b/docs/user_guide/capabilities/temperature-control.ipynb new file mode 100644 index 00000000000..e7becacb89f --- /dev/null +++ b/docs/user_guide/capabilities/temperature-control.ipynb @@ -0,0 +1,127 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Temperature Control\n", + "\n", + "{class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` provides heating and (optionally) active cooling. It is one of the most widely used capabilities -- found on heater-shakers, incubators, plate readers, and standalone thermal modules.\n", + "\n", + "Temperature controllers are machines that can heat and/or actively cool a material or enclosed volume from a room-temperature baseline (~20--25 \u00b0C). Multi-functional machines like heater-shakers, qPCR instruments, and smart storage systems build on top of this capability.\n", + "\n", + "## Actuation technologies\n", + "\n", + "Multiple technologies can be used to implement temperature control:\n", + "\n", + "- **Thermoelectric (Peltier) modules** -- solid-state devices that pump heat via the Peltier effect, enabling both heating and cooling by reversing current flow. Compact and modular, they mount directly on robotic decks. *Pros:* bidirectional control, fast response, minimal footprint. *Cons:* limited \u0394T from ambient (~\u00b165 \u00b0C max), efficiency drops near extremes.\n", + "\n", + "- **Liquid-circulation systems** -- external chillers or heaters pump fluid (water or glycol) through channels around a sample block, delivering uniform, stable temperatures well below and above ambient. *Pros:* broad temperature range, excellent uniformity. *Cons:* bulky, requires plumbing.\n", + "\n", + "## When to use\n", + "\n", + "Use this capability whenever you need to hold labware at a specific temperature: incubation at 37 \u00b0C, enzyme inactivation at 65 \u00b0C, keeping reagents cold at 4 \u00b0C, etc.\n", + "\n", + "## Walkthrough\n", + "\n", + "In this example we use a chatterbox (simulated) backend. On real hardware, the capability is accessed as an attribute on a device (e.g. `hs.tc` on a Hamilton Heater Shaker)." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.temperature_controlling import TemperatureController\n", + "from pylabrobot.capabilities.temperature_controlling.chatterbox import TemperatureControllerChatterboxBackend\n", + "\n", + "tc = TemperatureController(backend=TemperatureControllerChatterboxBackend())\n", + "await tc._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Set a target temperature and wait for it to stabilize. `set_temperature` sends the command and returns immediately. `wait_for_temperature` polls every 1 second until the target is reached (within `tolerance`)." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await tc.set_temperature(37.0)\n", + "await tc.wait_for_temperature(tolerance=0.5)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Read the current temperature at any time:" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "current = await tc.request_temperature()\n", + "print(f\"{current:.1f} \u00b0C\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Deactivate to stop heating/cooling and return to ambient. This resets `target_temperature` to `None`." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "await tc.deactivate()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tips and gotchas\n", + "\n", + "- **`set_temperature` does not wait.** It sends the command and returns. Use `wait_for_temperature` to block until the target is reached.\n", + "- **Passive cooling.** If you set a target below the current temperature and the backend doesn't support active cooling, you'll get a `ValueError`. Pass `passive=True` to allow the device to cool naturally.\n", + "- **`wait_for_temperature` polls every 1 second.** It raises `TimeoutError` after `timeout` seconds (default 300) and `RuntimeError` if no target has been set.\n", + "\n", + "## Supported hardware\n", + "\n", + "```{supported-devices} heating, cooling\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` and {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureControllerBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/tilting.ipynb b/docs/user_guide/capabilities/tilting.ipynb new file mode 100644 index 00000000000..b08329c1aa0 --- /dev/null +++ b/docs/user_guide/capabilities/tilting.ipynb @@ -0,0 +1,92 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Tilting\n", + "\n", + "{class}`~pylabrobot.capabilities.tilting.tilting.Tilter` controls tilt modules for angling plates or other labware.\n", + "\n", + "## When to use\n", + "\n", + "Use this to tilt plates for aspiration from low-volume wells, gravity-based liquid removal, or bead settling.\n", + "\n", + "## Walkthrough" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "from pylabrobot.capabilities.tilting import Tilter\n", + "from pylabrobot.capabilities.tilting.chatterbox import TilterChatterboxBackend\n", + "\n", + "tilter = Tilter(backend=TilterChatterboxBackend())\n", + "await tilter._on_setup()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Tilt to an absolute angle (0 = horizontal)\n", + "await tilter.set_angle(15)\n", + "print(f\"angle: {tilter.absolute_angle}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Tilt by a relative amount\n", + "await tilter.tilt(-5)\n", + "print(f\"angle: {tilter.absolute_angle}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "# Return to horizontal\n", + "await tilter.set_angle(0)\n", + "print(f\"angle: {tilter.absolute_angle}\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Supported hardware\n", + "\n", + "```{supported-devices} tilting\n", + "```\n", + "\n", + "## API reference\n", + "\n", + "See {class}`~pylabrobot.capabilities.tilting.tilting.Tilter` and {class}`~pylabrobot.capabilities.tilting.tilting.TilterBackend`." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/capabilities/weighing.md b/docs/user_guide/capabilities/weighing.md new file mode 100644 index 00000000000..6ef55ea619f --- /dev/null +++ b/docs/user_guide/capabilities/weighing.md @@ -0,0 +1,96 @@ +# Weighing + +{class}`~pylabrobot.capabilities.weighing.weighing.Scale` controls laboratory balances and scales. + +Automated scales are essential precision instruments in laboratory automation, providing +gravimetric measurements for liquid handling verification, formulation, and analytical workflows. + +While conceptually simple, proper integration requires understanding how scales handle zeroing, +taring, and measurement stability. + +--- + +## How scales calculate weight + +Understanding how scales calculate the displayed weight helps clarify the difference between +`zero()` and `tare()`: + +**Displayed Weight = (Current Sensor Reading - Zero Point) - Tare Weight** + +- **Zero Point**: The baseline sensor reading when you call `zero()` with an empty platform +- **Tare Weight**: The container weight stored in memory when you call `tare()` +- **Current Sensor Reading**: The actual load currently on the weighing platform + +**Important**: Neither `zero()` nor `tare()` restores the scale's capacity. If your scale +has a maximum capacity of 220g and you zero it with 5g already on the platform, you can only +add 215g more before reaching the limit. + +--- + +## Methods + +- **`zero() -> None`** -- Calibrates the scale to read zero when nothing is on the weighing platform. Unlike taring, this doesn't account for any container weight -- it simply establishes the baseline "empty" reading. You typically zero a scale at the start of a workflow or when you've removed all items from the platform and want to reset to true zero. + +```{figure} ../02_analytical/scales/img/scale_0_zero_example.png +:alt: Zero operation example +:align: center +``` + +- **`tare() -> None`** -- Resets the scale's reading to zero while accounting for the weight of a container or vessel already on the scale. This is essential when you want to measure only the weight of material being added to a container, ignoring the container's own weight. For example, when dispensing liquid into a beaker, you would first place the beaker on the scale, tare it, and then measure only the weight of any additional liquid added. + +```{figure} ../02_analytical/scales/img/scale_1_tare_example.png +:alt: Tare operation example +:align: center +``` + +- **`read_weight() -> float`** -- Retrieves the current weight measurement from the scale in grams. When you place an item on a scale or add material to a container, the scale doesn't instantly settle on a final value -- there's a brief period of oscillation as the measurement stabilizes. This is due to physical factors like vibrations, air currents, or the mechanical settling of the weighing mechanism. + +```{figure} ../02_analytical/scales/img/scale_2_read_measurement_example.png +:alt: Read weight operation example +:align: center +``` + +--- + +## Understanding the `timeout` parameter + +All three core methods (`zero()`, `tare()`, and `read_weight()`) accept a `timeout` +parameter that controls how the scale handles measurement stability. + +**Available timeout modes:** + +- **`timeout="stable"`** -- Wait for a stable reading. The scale will wait indefinitely until the measurement stabilizes. Stability is detected either by the scale's firmware (which monitors consecutive readings internally) or by PyLabRobot polling repeatedly until fluctuations fall below a threshold. Use this when accuracy is critical: formulation, analytical chemistry, quality control. + +- **`timeout=0`** -- Read immediately. Returns the current value without waiting, even if still fluctuating. You might get different values like 10.23g, 10.25g, 10.24g in quick succession. Use this for monitoring dynamic processes or when you need rapid feedback and can tolerate small variations. + +- **`timeout=n`** (seconds) -- Wait up to n seconds. Attempts to get a stable reading within the specified time. If the reading stabilizes before the timeout, it returns immediately. Otherwise, it returns the current value after n seconds (which may still be unstable). Use this as a compromise between accuracy and speed, or to prevent indefinite waiting. + +**Example usage:** + +```python +await scale.zero(timeout="stable") # Wait for stability +await scale.tare(timeout=5) # Wait max 5 seconds +weight_g = await scale.read_weight(timeout=0) # Read immediately +``` + +--- + +## Example + +```python +# on a scale: scale + +await scale.zero() +await scale.tare() # with container on scale + +weight = await scale.read_weight() +print(f"Net weight: {weight} g") +``` + +## Backend interface + +Implement {class}`~pylabrobot.capabilities.weighing.weighing.ScaleBackend`: + +- **`zero() -> None`** -- Zero the hardware. +- **`tare() -> None`** -- Tare the hardware. +- **`read_weight() -> float`** -- Return weight in grams. diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 8eb3b01e98f..5f3c37d64b1 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -39,6 +39,14 @@ machine-agnostic-features/error-handling-general machine-agnostic-features/sila-discovery ``` +```{toctree} +:maxdepth: 1 +:caption: Capabilities +:hidden: + +capabilities/index +``` + ```{toctree} :maxdepth: 1 :caption: Configuration diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py index 7eed8666cb7..3a401545a88 100644 --- a/pylabrobot/agilent/__init__.py +++ b/pylabrobot/agilent/__init__.py @@ -2,7 +2,6 @@ BioTekBackend, Cytation1, Cytation5, - Cytation5ImagingConfig, CytationBackend, CytationImagingConfig, SynergyH1, diff --git a/pylabrobot/agilent/biotek/__init__.py b/pylabrobot/agilent/biotek/__init__.py index 9ff9c00b02f..e7f0066b59e 100644 --- a/pylabrobot/agilent/biotek/__init__.py +++ b/pylabrobot/agilent/biotek/__init__.py @@ -2,7 +2,6 @@ from .cytation import ( Cytation1, Cytation5, - Cytation5ImagingConfig, CytationBackend, CytationImagingConfig, ) diff --git a/pylabrobot/agilent/biotek/biotek.py b/pylabrobot/agilent/biotek/biotek.py index fac9c935df4..09fc3331441 100644 --- a/pylabrobot/agilent/biotek/biotek.py +++ b/pylabrobot/agilent/biotek/biotek.py @@ -16,6 +16,7 @@ LuminescenceBackend, LuminescenceResult, ) +from pylabrobot.capabilities.temperature_controlling import TemperatureControllerBackend from pylabrobot.device import Driver from pylabrobot.io.ftdi import FTDI from pylabrobot.resources import Plate, Well @@ -25,7 +26,7 @@ class BioTekBackend( - AbsorbanceBackend, LuminescenceBackend, FluorescenceBackend, Driver, metaclass=ABCMeta + AbsorbanceBackend, LuminescenceBackend, FluorescenceBackend, TemperatureControllerBackend, Driver, metaclass=ABCMeta ): """Backend for Agilent BioTek plate readers.""" @@ -129,6 +130,10 @@ def supports_heating(self) -> bool: def supports_cooling(self) -> bool: return False + @property + def supports_active_cooling(self) -> bool: + return self.supports_cooling + @property def temperature_range(self) -> Tuple[Optional[float], Optional[float]]: max_temp = 45.0 if self.supports_heating else None @@ -243,6 +248,9 @@ async def set_temperature(self, temperature: float): async def stop_heating_or_cooling(self): return await self.send_command("g", "00000") + async def deactivate(self): + return await self.stop_heating_or_cooling() + def _parse_body(self, body: bytes) -> List[List[Optional[float]]]: assert self._plate is not None, "Plate must be set before reading data" plate = self._plate diff --git a/pylabrobot/agilent/biotek/cytation.py b/pylabrobot/agilent/biotek/cytation.py index 0a435dc04ac..122b53534f1 100644 --- a/pylabrobot/agilent/biotek/cytation.py +++ b/pylabrobot/agilent/biotek/cytation.py @@ -11,7 +11,7 @@ from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.microscopy import ( MicroscopyBackend, - MicroscopyCapability, + Microscopy, ) from pylabrobot.capabilities.microscopy.standard import ( Exposure, @@ -22,9 +22,10 @@ ImagingResult, Objective, ) -from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability -from pylabrobot.capabilities.plate_reading.fluorescence import FluorescenceCapability -from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence +from pylabrobot.capabilities.plate_reading.luminescence import Luminescence +from pylabrobot.capabilities.temperature_controlling import TemperatureController from pylabrobot.device import Device from pylabrobot.resources import Coordinate, Plate, PlateHolder, Resource from pylabrobot.serializer import SerializableMixin @@ -879,12 +880,13 @@ def __init__( model="Agilent BioTek Cytation 5", ) Device.__init__(self, driver=backend) - self._driver: CytationBackend = backend - self.absorbance = AbsorbanceCapability(backend=backend) - self.luminescence = LuminescenceCapability(backend=backend) - self.fluorescence = FluorescenceCapability(backend=backend) - self.microscopy = MicroscopyCapability(backend=backend) - self._capabilities = [self.absorbance, self.luminescence, self.fluorescence, self.microscopy] + self.driver: CytationBackend = backend + self.absorbance = Absorbance(backend=backend) + self.luminescence = Luminescence(backend=backend) + self.fluorescence = Fluorescence(backend=backend) + self.microscopy = Microscopy(backend=backend) + self.temperature = TemperatureController(backend=backend) + self._capabilities = [self.absorbance, self.luminescence, self.fluorescence, self.microscopy, self.temperature] self.plate_holder = PlateHolder( name=name + "_plate_holder", @@ -900,10 +902,10 @@ def serialize(self) -> dict: return {**Resource.serialize(self), **Device.serialize(self)} async def open(self, slow: bool = False) -> None: - await self._driver.open(slow=slow) + await self.driver.open(slow=slow) async def close(self, slow: bool = False) -> None: - await self._driver.close(slow=slow) + await self.driver.close(slow=slow) class Cytation1(Resource, Device): @@ -927,11 +929,10 @@ def __init__( model="Agilent BioTek Cytation 1", ) Device.__init__(self, driver=backend) - self._driver: BioTekBackend = backend - self.absorbance = AbsorbanceCapability(backend=backend) - self.luminescence = LuminescenceCapability(backend=backend) - self.fluorescence = FluorescenceCapability(backend=backend) - self._capabilities = [self.absorbance, self.luminescence, self.fluorescence] + self.driver: BioTekBackend = backend + self.microscopy = Microscopy(backend=backend) + self.temperature = TemperatureController(backend=backend) + self._capabilities = [self.microscopy, self.temperature] self.plate_holder = PlateHolder( name=name + "_plate_holder", @@ -947,11 +948,7 @@ def serialize(self) -> dict: return {**Resource.serialize(self), **Device.serialize(self)} async def open(self, slow: bool = False) -> None: - await self._driver.open(slow=slow) + await self.driver.open(slow=slow) async def close(self, slow: bool = False) -> None: - await self._driver.close(slow=slow) - - -# Deprecated aliases -Cytation5ImagingConfig = CytationImagingConfig + await self.driver.close(slow=slow) diff --git a/pylabrobot/agilent/biotek/synergy_h1.py b/pylabrobot/agilent/biotek/synergy_h1.py index af719bb8a1f..b51a541c968 100644 --- a/pylabrobot/agilent/biotek/synergy_h1.py +++ b/pylabrobot/agilent/biotek/synergy_h1.py @@ -12,9 +12,10 @@ FtdiError = Exception # type: ignore[misc,assignment] from pylabrobot.agilent.biotek.biotek import BioTekBackend -from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability -from pylabrobot.capabilities.plate_reading.fluorescence import FluorescenceCapability -from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence +from pylabrobot.capabilities.plate_reading.luminescence import Luminescence +from pylabrobot.capabilities.temperature_controlling import TemperatureController from pylabrobot.device import Device from pylabrobot.resources import Coordinate, PlateHolder, Resource @@ -124,11 +125,12 @@ def __init__( model="Agilent BioTek Synergy H1", ) Device.__init__(self, driver=backend) - self._driver: SynergyH1Backend = backend - self.absorbance = AbsorbanceCapability(backend=backend) - self.luminescence = LuminescenceCapability(backend=backend) - self.fluorescence = FluorescenceCapability(backend=backend) - self._capabilities = [self.absorbance, self.luminescence, self.fluorescence] + self.driver: SynergyH1Backend = backend + self.absorbance = Absorbance(backend=backend) + self.luminescence = Luminescence(backend=backend) + self.fluorescence = Fluorescence(backend=backend) + self.temperature = TemperatureController(backend=backend) + self._capabilities = [self.absorbance, self.luminescence, self.fluorescence, self.temperature] self.plate_holder = PlateHolder( name=name + "_plate_holder", @@ -144,7 +146,7 @@ def serialize(self) -> dict: return {**Resource.serialize(self), **Device.serialize(self)} async def open(self, slow: bool = False) -> None: - await self._driver.open(slow=slow) + await self.driver.open(slow=slow) async def close(self, slow: bool = False) -> None: - await self._driver.close(slow=slow) + await self.driver.close(slow=slow) diff --git a/pylabrobot/agilent/vspin/vspin.py b/pylabrobot/agilent/vspin/vspin.py index 49f973cb39f..dceba530277 100644 --- a/pylabrobot/agilent/vspin/vspin.py +++ b/pylabrobot/agilent/vspin/vspin.py @@ -11,7 +11,7 @@ from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.centrifuging import CentrifugeBackend as _NewCentrifugeBackend -from pylabrobot.capabilities.centrifuging import CentrifugingCapability +from pylabrobot.capabilities.centrifuging import Centrifuge from pylabrobot.capabilities.centrifuging.errors import ( BucketHasPlateError, BucketNoPlateError, @@ -214,13 +214,13 @@ class VSpinCentrifugeBackend(_NewCentrifugeBackend): """Translates CentrifugeBackend interface into VSpin driver commands.""" def __init__(self, driver: VSpinDriver): - self._driver = driver + self.driver = driver self._bucket_1_remainder: Optional[int] = None if driver.device_id is not None: self._bucket_1_remainder = _load_vspin_calibrations(driver.device_id) async def _on_setup(self): - driver = self._driver + driver = self.driver await driver.send_command(bytes.fromhex("aa01121f32")) for _ in range(8): @@ -290,9 +290,9 @@ def bucket_1_remainder(self) -> int: async def set_bucket_1_position_to_current(self) -> None: """Set the current position as bucket 1 position and save calibration.""" - current_position = await self._driver.request_position() - device_id = await self._driver.io.request_serial() - remainder = await self._driver.request_home_position() - current_position + current_position = await self.driver.request_position() + device_id = await self.driver.io.request_serial() + remainder = await self.driver.request_home_position() - current_position self._bucket_1_remainder = current_position % FULL_ROTATION _save_vspin_calibrations(device_id, remainder) @@ -300,9 +300,9 @@ async def request_bucket_1_position(self) -> int: """Get the bucket 1 position based on calibration.""" if self._bucket_1_remainder is None: raise bucket_1_not_set_error - home_position = await self._driver.request_home_position() + home_position = await self.driver.request_home_position() bucket_1_position_mod_full_rotation = home_position - self.bucket_1_remainder - current_position = await self._driver.request_position() + current_position = await self.driver.request_position() bucket_1_position = ( FULL_ROTATION * math.floor((current_position - bucket_1_position_mod_full_rotation) / FULL_ROTATION + 1) @@ -313,38 +313,38 @@ async def request_bucket_1_position(self) -> int: # -- CentrifugeBackend interface -- async def open_door(self): - if await self._driver.request_door_open(): + if await self.driver.request_door_open(): return - await self._driver.send_command(bytes.fromhex("aa022600062e")) + await self.driver.send_command(bytes.fromhex("aa022600062e")) await asyncio.sleep(4) async def close_door(self): - if not (await self._driver.request_door_open()): + if not (await self.driver.request_door_open()): return - await self._driver.send_command(bytes.fromhex("aa022600042c")) + await self.driver.send_command(bytes.fromhex("aa022600042c")) await asyncio.sleep(2) async def lock_door(self): - if await self._driver.request_door_open(): + if await self.driver.request_door_open(): raise RuntimeError("Cannot lock door while it is open.") - if await self._driver.request_door_locked(): + if await self.driver.request_door_locked(): return - await self._driver.send_command(bytes.fromhex("aa0226000028")) + await self.driver.send_command(bytes.fromhex("aa0226000028")) async def unlock_door(self): - if not await self._driver.request_door_locked(): + if not await self.driver.request_door_locked(): return - await self._driver.send_command(bytes.fromhex("aa022600042c")) + await self.driver.send_command(bytes.fromhex("aa022600042c")) async def lock_bucket(self): - if await self._driver.request_bucket_locked(): + if await self.driver.request_bucket_locked(): return - await self._driver.send_command(bytes.fromhex("aa022600072f")) + await self.driver.send_command(bytes.fromhex("aa022600072f")) async def unlock_bucket(self): - if not await self._driver.request_bucket_locked(): + if not await self.driver.request_bucket_locked(): return - await self._driver.send_command(bytes.fromhex("aa022600062e")) + await self.driver.send_command(bytes.fromhex("aa022600062e")) async def go_to_bucket1(self): await self.go_to_position(await self.request_bucket_1_position()) @@ -360,16 +360,16 @@ async def go_to_position(self, position: int): byte_string = bytes.fromhex("aa01d497") + position_bytes + bytes.fromhex("c3f52800d71a0000") sum_byte = (sum(byte_string) - 0xAA) & 0xFF byte_string += sum_byte.to_bytes(1, byteorder="little") - await self._driver.send_command(bytes.fromhex("aa0226000028")) - await self._driver.send_command(bytes.fromhex("aa0117021a")) - await self._driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._driver.send_command(bytes.fromhex("aa0117041c")) - await self._driver.send_command(bytes.fromhex("aa01170119")) - await self._driver.send_command(bytes.fromhex("aa010b0c")) - await self._driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._driver.send_command(byte_string) - - while abs(await self._driver.request_position() - position) > 10: + await self.driver.send_command(bytes.fromhex("aa0226000028")) + await self.driver.send_command(bytes.fromhex("aa0117021a")) + await self.driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self.driver.send_command(bytes.fromhex("aa0117041c")) + await self.driver.send_command(bytes.fromhex("aa01170119")) + await self.driver.send_command(bytes.fromhex("aa010b0c")) + await self.driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self.driver.send_command(byte_string) + + while abs(await self.driver.request_position() - position) > 10: await asyncio.sleep(0.1) await self.open_door() @@ -412,11 +412,11 @@ async def spin( if duration < 1: raise ValueError("Spin time must be at least 1 second") - if await self._driver.request_door_open(): + if await self.driver.request_door_open(): await self.close_door() - if not await self._driver.request_door_locked(): + if not await self.driver.request_door_locked(): await self.lock_door() - if await self._driver.request_bucket_locked(): + if await self.driver.request_bucket_locked(): await self.unlock_bucket() rpm = VSpinCentrifugeBackend.g_to_rpm(g) @@ -428,7 +428,7 @@ async def spin( distance_at_speed = ticks_per_second * duration - current_position = await self._driver.request_position() + current_position = await self.driver.request_position() final_position = int(current_position + distance_during_acceleration + distance_at_speed) if final_position > 2**32 - 1: @@ -445,52 +445,52 @@ async def spin( checksum = (sum(byte_string) - 0xAA) & 0xFF byte_string += checksum.to_bytes(1, byteorder="little") - await self._driver.send_command(bytes.fromhex("aa0226000028")) - await self._driver.send_command(bytes.fromhex("aa0117021a")) - await self._driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._driver.send_command(bytes.fromhex("aa0117041c")) - await self._driver.send_command(bytes.fromhex("aa01170119")) - await self._driver.send_command(bytes.fromhex("aa010b0c")) - await self._driver.send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + await self.driver.send_command(bytes.fromhex("aa0226000028")) + await self.driver.send_command(bytes.fromhex("aa0117021a")) + await self.driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self.driver.send_command(bytes.fromhex("aa0117041c")) + await self.driver.send_command(bytes.fromhex("aa01170119")) + await self.driver.send_command(bytes.fromhex("aa010b0c")) + await self.driver.send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) - await self._driver.send_command(byte_string) + await self.driver.send_command(byte_string) while ( - await self._driver.request_tachometer() < rpm * 0.95 - and await self._driver.request_position() < final_position + await self.driver.request_tachometer() < rpm * 0.95 + and await self.driver.request_position() < final_position ): await asyncio.sleep(0.1) - if await self._driver.request_position() < final_position: - decel_start_position = await self._driver.request_position() + distance_at_speed + if await self.driver.request_position() < final_position: + decel_start_position = await self.driver.request_position() + distance_at_speed - while await self._driver.request_position() < decel_start_position: + while await self.driver.request_position() < decel_start_position: await asyncio.sleep(0.1) - await self._driver.send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) + await self.driver.send_command(bytes.fromhex("aa01e60500640000000000fd00803e01000c")) decc = int(9.15 * 100 * deceleration).to_bytes(2, byteorder="little") decel_command = bytes.fromhex("aa0194b600000000") + decc + bytes.fromhex("0000") decel_command += ((sum(decel_command) - 0xAA) & 0xFF).to_bytes(1, byteorder="little") - await self._driver.send_command(decel_command) + await self.driver.send_command(decel_command) await asyncio.sleep(2) async def _reset_to_zero(): - await self._driver.send_command(bytes.fromhex("aa0117021a")) - await self._driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) - await self._driver.send_command(bytes.fromhex("aa0117041c")) - await self._driver.send_command(bytes.fromhex("aa01170119")) - await self._driver.send_command(bytes.fromhex("aa010b0c")) - await self._driver.send_command(bytes.fromhex("aa010001")) - await self._driver.send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) - await self._driver.send_command(bytes.fromhex("aa0194b61283000012010000f3")) - await self._driver.send_command(bytes.fromhex("aa01192842")) + await self.driver.send_command(bytes.fromhex("aa0117021a")) + await self.driver.send_command(bytes.fromhex("aa01e6c800b00496000f004b00a00f050007")) + await self.driver.send_command(bytes.fromhex("aa0117041c")) + await self.driver.send_command(bytes.fromhex("aa01170119")) + await self.driver.send_command(bytes.fromhex("aa010b0c")) + await self.driver.send_command(bytes.fromhex("aa010001")) + await self.driver.send_command(bytes.fromhex("aa01e605006400000000003200e80301006e")) + await self.driver.send_command(bytes.fromhex("aa0194b61283000012010000f3")) + await self.driver.send_command(bytes.fromhex("aa01192842")) await _reset_to_zero() - start = await self._driver.request_home_position() + start = await self.driver.request_home_position() num_tries = 0 - while await self._driver.request_home_position() == start: + while await self.driver.request_home_position() == start: await asyncio.sleep(0.1) num_tries += 1 if num_tries % 25 == 0: @@ -636,7 +636,7 @@ def __init__( category="centrifuge", ) Device.__init__(self, driver=driver) - self._driver: VSpinDriver = driver + self.driver: VSpinDriver = driver bucket1 = ResourceHolder( name=f"{name}_bucket1", @@ -655,7 +655,7 @@ def __init__( self.assign_child_resource(bucket1, location=Coordinate.zero()) self.assign_child_resource(bucket2, location=Coordinate.zero()) - self.centrifuging = CentrifugingCapability( + self.centrifuging = Centrifuge( backend=VSpinCentrifugeBackend(driver), buckets=(bucket1, bucket2) ) self._capabilities = [self.centrifuging] @@ -688,7 +688,7 @@ def __init__( child_location=Coordinate.zero(), ) Device.__init__(self, driver=driver) - self._driver: Access2Driver = driver + self.driver: Access2Driver = driver self._vspin = vspin def serialize(self) -> dict: @@ -709,7 +709,7 @@ async def load(self) -> None: if centrifuging.at_bucket.resource is not None: raise BucketHasPlateError("Bucket must be empty to load a plate.") - await self._driver.load() + await self.driver.load() centrifuging.at_bucket.assign_child_resource(self.resource, location=Coordinate.zero()) @@ -726,6 +726,6 @@ async def unload(self) -> None: if centrifuging.at_bucket.resource is None: raise BucketNoPlateError("Bucket must have a plate to unload.") - await self._driver.unload() + await self.driver.unload() self.assign_child_resource(centrifuging.at_bucket.resource) diff --git a/pylabrobot/agrowpumps/agrowdosepump_backend.py b/pylabrobot/agrowpumps/agrowdosepump_backend.py index 230c9b969e1..bb6fb107ee2 100644 --- a/pylabrobot/agrowpumps/agrowdosepump_backend.py +++ b/pylabrobot/agrowpumps/agrowdosepump_backend.py @@ -15,7 +15,7 @@ from pylabrobot.capabilities.capability import Capability from pylabrobot.capabilities.pumping.backend import PumpBackend from pylabrobot.capabilities.pumping.calibration import PumpCalibration -from pylabrobot.capabilities.pumping.pumping import PumpingCapability +from pylabrobot.capabilities.pumping.pumping import Pump from pylabrobot.device import Device, Driver logger = logging.getLogger("pylabrobot") @@ -135,7 +135,7 @@ class AgrowChannelBackend(PumpBackend): """Per-channel PumpBackend adapter that delegates to a shared AgrowDriver.""" def __init__(self, connection: AgrowDriver, channel: int): - self._driver = connection + self.driver = connection self._channel = channel async def run_revolutions(self, num_revolutions: float): @@ -144,15 +144,15 @@ async def run_revolutions(self, num_revolutions: float): ) async def run_continuously(self, speed: float): - await self._driver.write_speed(self._channel, int(speed)) + await self.driver.write_speed(self._channel, int(speed)) async def halt(self): - await self._driver.write_speed(self._channel, 0) + await self.driver.write_speed(self._channel, 0) def serialize(self): return { - "port": self._driver.port, - "address": self._driver.address, + "port": self.driver.port, + "address": self.driver.address, "channel": self._channel, } @@ -160,7 +160,7 @@ def serialize(self): class AgrowDosePumpArray(Device): """Agrow dose pump array device. - Exposes each channel as an individual PumpingCapability via `self.pumps`. + Exposes each channel as an individual Pump via `self.pumps`. """ def __init__( @@ -170,22 +170,22 @@ def __init__( calibrations: Optional[List[Optional[PumpCalibration]]] = None, ): self._channel_backends: List[AgrowChannelBackend] = [] - self.pumps: List[PumpingCapability] = [] + self.pumps: List[Pump] = [] self._calibrations = calibrations super().__init__(driver=AgrowDriver(port=port, address=address)) - self._driver: AgrowDriver + self.driver: AgrowDriver async def setup(self): - await self._driver.setup() - num_channels = self._driver.num_channels + await self.driver.setup() + num_channels = self.driver.num_channels - self._channel_backends = [AgrowChannelBackend(self._driver, ch) for ch in range(num_channels)] + self._channel_backends = [AgrowChannelBackend(self.driver, ch) for ch in range(num_channels)] self.pumps = [] for i, backend in enumerate(self._channel_backends): cal = None if self._calibrations is not None and i < len(self._calibrations): cal = self._calibrations[i] - cap = PumpingCapability(backend=backend, calibration=cal) + cap = Pump(backend=backend, calibration=cal) self.pumps.append(cap) self._capabilities: List[Capability] = list(self.pumps) @@ -196,11 +196,11 @@ async def setup(self): async def stop(self): for cap in reversed(self._capabilities): await cap._on_stop() - await self._driver.stop() + await self.driver.stop() self._setup_finished = False def serialize(self): return { - "port": self._driver.port, - "address": self._driver.address, + "port": self.driver.port, + "address": self.driver.address, } diff --git a/pylabrobot/agrowpumps/agrowdosepump_tests.py b/pylabrobot/agrowpumps/agrowdosepump_tests.py index 3503185d47d..5c53861b2ef 100644 --- a/pylabrobot/agrowpumps/agrowdosepump_tests.py +++ b/pylabrobot/agrowpumps/agrowdosepump_tests.py @@ -40,27 +40,27 @@ async def asyncSetUp(self): self.device = AgrowDosePumpArray(port="simulated", address=1) async def _mock_setup_modbus(): - self.device._driver._modbus = SimulatedModbusClient() + self.device.driver._modbus = SimulatedModbusClient() - with patch.object(self.device._driver, "_setup_modbus", _mock_setup_modbus): + with patch.object(self.device.driver, "_setup_modbus", _mock_setup_modbus): await self.device.setup() async def asyncTearDown(self): await self.device.stop() async def test_setup(self): - self.assertEqual(self.device._driver.port, "simulated") - self.assertEqual(self.device._driver.address, 1) + self.assertEqual(self.device.driver.port, "simulated") + self.assertEqual(self.device.driver.address, 1) self.assertEqual(len(self.device.pumps), 6) self.assertEqual( - self.device._driver._pump_index_to_address, + self.device.driver._pump_index_to_address, {pump: pump + 100 for pump in range(0, 6)}, ) async def test_run_continuously(self): - self.device._driver.modbus.write_register.reset_mock() + self.device.driver.modbus.write_register.reset_mock() await self.device.pumps[0].run_continuously(speed=1) - self.device._driver.modbus.write_register.assert_called_once_with(100, 1, unit=1) + self.device.driver.modbus.write_register.assert_called_once_with(100, 1, unit=1) # invalid speed: cannot be bigger than 100 with self.assertRaises(ValueError): @@ -71,6 +71,6 @@ async def test_run_revolutions(self): await self.device.pumps[0].run_revolutions(num_revolutions=1.0) async def test_halt_single_channel(self): - self.device._driver.modbus.write_register.reset_mock() + self.device.driver.modbus.write_register.reset_mock() await self.device.pumps[2].halt() - self.device._driver.modbus.write_register.assert_called_once_with(102, 0, unit=1) + self.device.driver.modbus.write_register.assert_called_once_with(102, 0, unit=1) diff --git a/pylabrobot/arms/__init__.py b/pylabrobot/arms/__init__.py index 480e32242ed..7afba7fa549 100644 --- a/pylabrobot/arms/__init__.py +++ b/pylabrobot/arms/__init__.py @@ -1,4 +1,5 @@ from .arm import * +from .articulated_arm import * from .backend import * from .orientable_arm import * from .standard import * diff --git a/pylabrobot/arms/articulated_arm.py b/pylabrobot/arms/articulated_arm.py new file mode 100644 index 00000000000..1809ee4d310 --- /dev/null +++ b/pylabrobot/arms/articulated_arm.py @@ -0,0 +1,167 @@ +from typing import List, Optional, Union + +from pylabrobot.arms.arm import _BaseArm, _PickedUpState +from pylabrobot.arms.backend import ArticulatedGripperArmBackend +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Coordinate, Resource, ResourceHolder, ResourceStack +from pylabrobot.resources.rotation import Rotation + + +class ArticulatedArm(_BaseArm): + """An arm with full 3D rotation capability. E.g. a 6-axis robot arm.""" + + def __init__(self, backend: ArticulatedGripperArmBackend, reference_resource: Resource): + super().__init__(backend=backend, reference_resource=reference_resource) + self.backend: ArticulatedGripperArmBackend = backend # type: ignore[assignment] + + async def open_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + await self.backend.open_gripper(gripper_width=gripper_width, backend_params=backend_params) + + async def close_gripper( + self, gripper_width: float, backend_params: Optional[BackendParams] = None + ) -> None: + await self.backend.close_gripper(gripper_width=gripper_width, backend_params=backend_params) + + async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None) -> bool: + return await self.backend.is_gripper_closed(backend_params=backend_params) + + @staticmethod + def _resource_width_for_rotation(resource: Resource, rotation: Rotation) -> float: + if rotation.z % 180 == 0: + return resource.get_absolute_size_x() + else: + return resource.get_absolute_size_y() + + async def pick_up_at_location( + self, + location: Coordinate, + resource_width: float, + rotation: Rotation = Rotation(), + backend_params: Optional[BackendParams] = None, + ): + self._begin_holding(resource_width) + await self.backend.pick_up_at_location( + location=location, + rotation=rotation, + resource_width=resource_width, + backend_params=backend_params, + ) + + async def pick_up_resource( + self, + resource: Resource, + offset: Coordinate = Coordinate.zero(), + pickup_distance_from_top: Optional[float] = None, + rotation: Rotation = Rotation(), + backend_params: Optional[BackendParams] = None, + ): + location, pickup_distance_from_top = self._prepare_pickup( + resource, offset, pickup_distance_from_top + ) + resource_width = self._resource_width_for_rotation(resource, rotation) + await self.pick_up_at_location(location, resource_width, rotation, backend_params) + self._picked_up = _PickedUpState( + resource=resource, + offset=offset, + pickup_distance_from_top=pickup_distance_from_top, + resource_width=resource_width, + rotation=rotation, + ) + self._state_updated() + + async def drop_at_location( + self, + location: Coordinate, + rotation: Rotation, + backend_params: Optional[BackendParams] = None, + ): + if self._holding_resource_width is None: + raise RuntimeError("Not holding anything") + await self.backend.drop_at_location( + location=location, + rotation=rotation, + resource_width=self._holding_resource_width, + backend_params=backend_params, + ) + self._end_holding() + + async def drop_resource( + self, + destination: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + offset: Coordinate = Coordinate.zero(), + rotation: Rotation = Rotation(), + backend_params: Optional[BackendParams] = None, + ): + resource = self._prepare_drop(destination) + if self._picked_up is None: + raise RuntimeError("No resource picked up") + drop_z = rotation.z + rotation_applied_by_move = (drop_z - self._picked_up.rotation.z) % 360 + location, final_rotation = self._compute_drop( + resource=resource, + destination=destination, + offset=offset, + pickup_distance_from_top=self._picked_up.pickup_distance_from_top, + rotation_applied_by_move=rotation_applied_by_move, + ) + await self.drop_at_location(location, rotation, backend_params) + self._finalize_drop(resource, destination, final_rotation) + + async def move_to_location( + self, + location: Coordinate, + rotation: Rotation = Rotation(), + backend_params: Optional[BackendParams] = None, + ): + await self.backend.move_to_location( + location=location, + rotation=rotation, + backend_params=backend_params, + ) + + async def move_picked_up_resource( + self, + to: Coordinate, + rotation: Rotation, + offset: Coordinate = Coordinate.zero(), + backend_params: Optional[BackendParams] = None, + ): + if self._picked_up is None: + raise RuntimeError("No resource picked up") + location = self._move_location( + self._picked_up.resource, to, offset, self._picked_up.pickup_distance_from_top + ) + await self.backend.move_to_location( + location=location, rotation=rotation, backend_params=backend_params + ) + + async def move_resource( + self, + resource: Resource, + to: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + intermediate_locations: Optional[List[Coordinate]] = None, + pickup_offset: Coordinate = Coordinate.zero(), + destination_offset: Coordinate = Coordinate.zero(), + pickup_distance_from_top: float = 0, + pickup_rotation: Rotation = Rotation(), + drop_rotation: Rotation = Rotation(), + pickup_backend_params: Optional[BackendParams] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + await self.pick_up_resource( + resource=resource, + offset=pickup_offset, + pickup_distance_from_top=pickup_distance_from_top, + rotation=pickup_rotation, + backend_params=pickup_backend_params, + ) + for loc in intermediate_locations or []: + await self.move_picked_up_resource(to=loc, rotation=drop_rotation) + await self.drop_resource( + destination=to, + offset=destination_offset, + rotation=drop_rotation, + backend_params=drop_backend_params, + ) diff --git a/pylabrobot/arms/orientable_arm.py b/pylabrobot/arms/orientable_arm.py index 57b46e21609..3c9cc4c2b6e 100644 --- a/pylabrobot/arms/orientable_arm.py +++ b/pylabrobot/arms/orientable_arm.py @@ -1,4 +1,4 @@ -from typing import Optional, Union +from typing import List, Optional, Union from pylabrobot.arms.arm import GripOrientation, _BaseArm, _PickedUpState from pylabrobot.arms.backend import OrientableGripperArmBackend @@ -157,3 +157,32 @@ async def move_picked_up_resource( await self.backend.move_to_location( location=location, direction=dir_degrees, backend_params=backend_params ) + + async def move_resource( + self, + resource: Resource, + to: Union[ResourceStack, ResourceHolder, Resource, Coordinate], + intermediate_locations: Optional[List[Coordinate]] = None, + pickup_offset: Coordinate = Coordinate.zero(), + destination_offset: Coordinate = Coordinate.zero(), + pickup_distance_from_top: float = 0, + pickup_direction: GripOrientation = GripDirection.FRONT, + drop_direction: GripOrientation = GripDirection.FRONT, + pickup_backend_params: Optional[BackendParams] = None, + drop_backend_params: Optional[BackendParams] = None, + ): + await self.pick_up_resource( + resource=resource, + offset=pickup_offset, + pickup_distance_from_top=pickup_distance_from_top, + direction=pickup_direction, + backend_params=pickup_backend_params, + ) + for loc in intermediate_locations or []: + await self.move_picked_up_resource(to=loc, direction=drop_direction) + await self.drop_resource( + destination=to, + offset=destination_offset, + direction=drop_direction, + backend_params=drop_backend_params, + ) diff --git a/pylabrobot/azenta/a4s.py b/pylabrobot/azenta/a4s.py index 4240c2bf783..4a9231f5300 100644 --- a/pylabrobot/azenta/a4s.py +++ b/pylabrobot/azenta/a4s.py @@ -12,9 +12,9 @@ HAS_SERIAL = False _SERIAL_IMPORT_ERROR = e -from pylabrobot.capabilities.sealing import SealerBackend, SealingCapability +from pylabrobot.capabilities.sealing import SealerBackend, Sealer from pylabrobot.capabilities.temperature_controlling import ( - TemperatureControlCapability, + TemperatureController, TemperatureControllerBackend, ) from pylabrobot.device import Device, Driver @@ -208,30 +208,30 @@ class A4SSealerBackend(SealerBackend): """Translates SealerBackend operations into A4S driver commands.""" def __init__(self, driver: A4SDriver): - self._driver = driver + self.driver = driver async def seal(self, temperature: int, duration: float): - await self._driver.send_command(f"*00DH={round(temperature):04d}zz!") + await self.driver.send_command(f"*00DH={round(temperature):04d}zz!") await self._wait_for_temperature(temperature, timeout=300) - await self._driver.set_time(duration) - await self._driver.send_command("*00GS=zz!") - await self._driver.wait_for_status({A4SStatus.SystemStatus.single_cycle}) - return await self._driver.wait_for_status( + await self.driver.set_time(duration) + await self.driver.send_command("*00GS=zz!") + await self.driver.wait_for_status({A4SStatus.SystemStatus.single_cycle}) + return await self.driver.wait_for_status( {A4SStatus.SystemStatus.idle, A4SStatus.SystemStatus.finish} ) async def open(self): - await self._driver.send_command("*00MO=zz!") - return await self._driver.wait_for_shuttle_open_sensor(True) + await self.driver.send_command("*00MO=zz!") + return await self.driver.wait_for_shuttle_open_sensor(True) async def close(self): - await self._driver.send_command("*00MC=zz!") - return await self._driver.wait_for_shuttle_open_sensor(False) + await self.driver.send_command("*00MC=zz!") + return await self.driver.wait_for_shuttle_open_sensor(False) async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: float = 0.5): start = time.time() while True: - status = await self._driver.request_status() + status = await self.driver.request_status() if abs(status.current_temperature - degrees) < tolerance: break if time.time() - start > timeout: @@ -243,7 +243,7 @@ class A4STemperatureBackend(TemperatureControllerBackend): """Translates TemperatureControllerBackend operations into A4S driver commands.""" def __init__(self, driver: A4SDriver): - self._driver = driver + self.driver = driver @property def supports_active_cooling(self) -> bool: @@ -253,7 +253,7 @@ async def set_temperature(self, temperature: float): if not (50 <= temperature <= 200): raise ValueError("Temperature out of range. Please enter a value between 50 and 200.") command = f"*00DH={round(temperature):04d}zz!" - await self._driver.send_command(command) + await self.driver.send_command(command) await self._wait_for_temperature(temperature, timeout=300) async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: float = 0.5): @@ -267,11 +267,11 @@ async def _wait_for_temperature(self, degrees: float, timeout: float, tolerance: await asyncio.sleep(0.1) async def request_current_temperature(self) -> float: - status = await self._driver.request_status() + status = await self.driver.request_status() return status.current_temperature async def deactivate(self): - await self._driver.set_heater(on=False) + await self.driver.set_heater(on=False) class A4S(PlateHolder, Device): @@ -307,9 +307,9 @@ def __init__( model=model, ) Device.__init__(self, driver=driver) - self._driver: A4SDriver = driver - self.sealer = SealingCapability(backend=A4SSealerBackend(driver)) - self.tc = TemperatureControlCapability(backend=A4STemperatureBackend(driver)) + self.driver: A4SDriver = driver + self.sealer = Sealer(backend=A4SSealerBackend(driver)) + self.tc = TemperatureController(backend=A4STemperatureBackend(driver)) self._capabilities = [self.tc, self.sealer] def serialize(self) -> dict: diff --git a/pylabrobot/azenta/xpeel.py b/pylabrobot/azenta/xpeel.py index e67fd37fba8..0a5141effe2 100644 --- a/pylabrobot/azenta/xpeel.py +++ b/pylabrobot/azenta/xpeel.py @@ -12,7 +12,7 @@ _SERIAL_IMPORT_ERROR = e from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.capabilities.peeling import PeelerBackend, PeelingCapability +from pylabrobot.capabilities.peeling import PeelerBackend, Peeler from pylabrobot.device import Device, Driver from pylabrobot.io.serial import Serial from pylabrobot.serializer import SerializableMixin @@ -241,7 +241,7 @@ class PeelParams(BackendParams): adhere_time: float = 2.5 def __init__(self, driver: XPeelDriver): - self._driver = driver + self.driver = driver async def peel( self, @@ -272,11 +272,11 @@ async def peel( }.get((begin_location, fast), 9) cmd = f"*xpeel:{parameter_set}{adhere_time}" - return await self._driver.send_command(cmd, expect_ack=True, wait_for_ready=True) + return await self.driver.send_command(cmd, expect_ack=True, wait_for_ready=True) async def restart(self, backend_params: Optional[SerializableMixin] = None): """Request restart with full homing sequence.""" - return await self._driver.send_command("*restart", expect_ack=True, wait_for_ready=True) + return await self.driver.send_command("*restart", expect_ack=True, wait_for_ready=True) class XPeel(Device): @@ -285,6 +285,6 @@ class XPeel(Device): def __init__(self, name: str, port: str, timeout: Optional[float] = None): driver = XPeelDriver(port=port, timeout=timeout) super().__init__(driver=driver) - self._driver: XPeelDriver = driver - self.peeler = PeelingCapability(backend=XPeelPeelerBackend(driver)) + self.driver: XPeelDriver = driver + self.peeler = Peeler(backend=XPeelPeelerBackend(driver)) self._capabilities = [self.peeler] diff --git a/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py b/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py index 3c73e045260..1d934759cff 100644 --- a/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py +++ b/pylabrobot/bmg_labtech/clariostar/absorbance_backend.py @@ -29,7 +29,7 @@ class CLARIOstarAbsorbanceBackend(AbsorbanceBackend): """Translates AbsorbanceBackend interface into CLARIOstar driver commands.""" def __init__(self, driver: CLARIOstarDriver): - self._driver = driver + self.driver = driver # Keep the nested class for backward compat with the legacy wrapper that references # ``CLARIOstarBackend.AbsorbanceParams``. The canonical name is now @@ -49,20 +49,20 @@ async def read_absorbance( if wells != plate.get_all_items(): raise NotImplementedError("Only full plate reads are supported for now.") - await self._driver.mp_and_focus_height_value() + await self.driver.mp_and_focus_height_value() wavelength_data = int(wavelength * 10).to_bytes(2, byteorder="big") - plate_bytes = self._driver.plate_bytes(plate) + plate_bytes = self.driver.plate_bytes(plate) payload = ( b"\x04" + plate_bytes + b"\x82\x02\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27\x0f\x27" b"\x0f\x19\x01" + wavelength_data + b"\x00\x00\x00\x64\x00\x00\x00\x00\x00\x00\x00\x64\x00" b"\x00\x00\x00\x00\x02\x00\x00\x00\x00\x01\x00\x00\x00\x01\x00\x16\x00\x01\x00\x00" ) - await self._driver.run_measurement(payload) - await self._driver.read_order_values() - await self._driver.status_hw() + await self.driver.run_measurement(payload) + await self.driver.read_order_values() + await self.driver.status_hw() - vals = await self._driver.request_measurement_values() + vals = await self.driver.request_measurement_values() num_wells = plate.num_items div = b"\x00" * 6 start_idx = vals.index(div) + len(div) diff --git a/pylabrobot/bmg_labtech/clariostar/clariostar.py b/pylabrobot/bmg_labtech/clariostar/clariostar.py index 1c11ea58757..9d6e0636bc3 100644 --- a/pylabrobot/bmg_labtech/clariostar/clariostar.py +++ b/pylabrobot/bmg_labtech/clariostar/clariostar.py @@ -1,8 +1,8 @@ from typing import Optional -from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability -from pylabrobot.capabilities.plate_reading.fluorescence import FluorescenceCapability -from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence +from pylabrobot.capabilities.plate_reading.luminescence import Luminescence from pylabrobot.device import Device from pylabrobot.resources import Coordinate, PlateHolder, Resource @@ -33,10 +33,10 @@ def __init__( model="BMG CLARIOstar", ) Device.__init__(self, driver=driver) - self._driver: CLARIOstarDriver = driver - self.absorbance = AbsorbanceCapability(backend=CLARIOstarAbsorbanceBackend(driver)) - self.luminescence = LuminescenceCapability(backend=CLARIOstarLuminescenceBackend(driver)) - self.fluorescence = FluorescenceCapability(backend=CLARIOstarFluorescenceBackend(driver)) + self.driver: CLARIOstarDriver = driver + self.absorbance = Absorbance(backend=CLARIOstarAbsorbanceBackend(driver)) + self.luminescence = Luminescence(backend=CLARIOstarLuminescenceBackend(driver)) + self.fluorescence = Fluorescence(backend=CLARIOstarFluorescenceBackend(driver)) self._capabilities = [self.absorbance, self.luminescence, self.fluorescence] self.plate_holder = PlateHolder( @@ -54,8 +54,8 @@ def serialize(self) -> dict: async def open(self) -> None: """Open the plate tray.""" - await self._driver.open() + await self.driver.open() async def close(self) -> None: """Close the plate tray.""" - await self._driver.close() + await self.driver.close() diff --git a/pylabrobot/bmg_labtech/clariostar/fluorescence_backend.py b/pylabrobot/bmg_labtech/clariostar/fluorescence_backend.py index fefc562e369..54f6c39c1fa 100644 --- a/pylabrobot/bmg_labtech/clariostar/fluorescence_backend.py +++ b/pylabrobot/bmg_labtech/clariostar/fluorescence_backend.py @@ -15,7 +15,7 @@ class CLARIOstarFluorescenceBackend(FluorescenceBackend): """Translates FluorescenceBackend interface into CLARIOstar driver commands.""" def __init__(self, driver: CLARIOstarDriver): - self._driver = driver + self.driver = driver async def read_fluorescence( self, diff --git a/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py b/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py index a99cafca776..b0d437bae92 100644 --- a/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py +++ b/pylabrobot/bmg_labtech/clariostar/luminescence_backend.py @@ -18,7 +18,7 @@ class CLARIOstarLuminescenceBackend(LuminescenceBackend): """Translates LuminescenceBackend interface into CLARIOstar driver commands.""" def __init__(self, driver: CLARIOstarDriver): - self._driver = driver + self.driver = driver async def read_luminescence( self, @@ -30,22 +30,22 @@ async def read_luminescence( if wells != plate.get_all_items(): raise NotImplementedError("Only full plate reads are supported for now.") - await self._driver.mp_and_focus_height_value() + await self.driver.mp_and_focus_height_value() assert 0 <= focal_height <= 25, "focal height must be between 0 and 25 mm" focal_height_data = int(focal_height * 100).to_bytes(2, byteorder="big") - plate_bytes = self._driver.plate_bytes(plate) + plate_bytes = self.driver.plate_bytes(plate) payload = ( b"\x04" + plate_bytes + b"\x02\x01\x00\x00\x00\x00\x00\x00\x00\x20\x04\x00\x1e\x27" b"\x0f\x27\x0f\x01" + focal_height_data + b"\x00\x00\x01\x00\x00\x0e\x10\x00\x01\x00\x01" b"\x00\x01\x00\x01\x00\x01\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00" b"\x00\x01\x00\x00\x00\x01\x00\x64\x00\x20\x00\x00" ) - await self._driver.run_measurement(payload) - await self._driver.read_order_values() - await self._driver.status_hw() + await self.driver.run_measurement(payload) + await self.driver.read_order_values() + await self.driver.status_hw() - vals = await self._driver.request_measurement_values() + vals = await self.driver.request_measurement_values() num_wells = plate.num_items start_idx = vals.index(b"\x00\x00\x00\x00\x00\x00") + len(b"\x00\x00\x00\x00\x00\x00") data = list(vals)[start_idx : start_idx + num_wells * 4] diff --git a/pylabrobot/brooks/precise_flex.py b/pylabrobot/brooks/precise_flex.py index 0e2c1bd32e2..cd349a1a261 100644 --- a/pylabrobot/brooks/precise_flex.py +++ b/pylabrobot/brooks/precise_flex.py @@ -334,7 +334,7 @@ def __init__( has_rail: bool = False, ) -> None: super().__init__() - self._driver = driver + self.driver = driver self.profile_index: int = 1 self.location_index: int = 1 self._rail_position_index = 1 @@ -397,25 +397,25 @@ async def open_gripper( ): """Open the gripper to the specified width.""" await self._set_grip_open_pos(gripper_width) - await self._driver.send_command("gripper 1") + await self.driver.send_command("gripper 1") async def close_gripper( self, gripper_width: float, backend_params: Optional[BackendParams] = None ): """Close the gripper to the specified width.""" await self._set_grip_close_pos(gripper_width) - await self._driver.send_command("gripper 2") + await self.driver.send_command("gripper 2") async def halt(self, backend_params: Optional[BackendParams] = None): """Stops the current robot immediately but leaves power on.""" - await self._driver.send_command("halt") + await self.driver.send_command("halt") async def move_to_safe(self) -> None: """Moves the robot to Safe Position. Does not include checks for collision with 3rd party obstacles inside the work volume of the robot. """ - await self._driver.send_command("movetosafe") + await self.driver.send_command("movetosafe") async def approach( self, @@ -536,10 +536,10 @@ async def request_joint_position( self, backend_params: Optional[BackendParams] = None ) -> Dict[int, float]: """Get the current joint position of the arm.""" - await self._driver._wait_for_eom() + await self.driver._wait_for_eom() num_tries = 2 for _ in range(num_tries): - data = await self._driver.send_command("wherej") + data = await self.driver.send_command("wherej") parts = data.split() if len(parts) > 0: break @@ -551,10 +551,10 @@ async def request_gripper_location( self, backend_params: Optional[BackendParams] = None ) -> PreciseFlexGripperLocation: """Get the current position of the arm in Cartesian space.""" - await self._driver._wait_for_eom() + await self.driver._wait_for_eom() num_tries = 2 for _ in range(num_tries): - data = await self._driver.send_command("wherec") + data = await self.driver.send_command("wherec") parts = data.split() if len(parts) == 7: break @@ -663,14 +663,14 @@ async def is_gripper_closed(self, backend_params: Optional[BackendParams] = None """ if self._is_dual_gripper: raise ValueError("IsGripperClosed command is only valid for single gripper robots.") - response = await self._driver.send_command("IsFullyClosed") + response = await self.driver.send_command("IsFullyClosed") return int(response) == -1 async def are_grippers_closed(self) -> tuple[bool, bool]: """(Dual Gripper Only) Tests if each gripper is fully closed by checking the end-of-travel sensors.""" if not self._is_dual_gripper: raise ValueError("AreGrippersClosed command is only valid for dual gripper robots.") - response = await self._driver.send_command("IsFullyClosed") + response = await self.driver.send_command("IsFullyClosed") ret_int = int(response) gripper_1_closed = (ret_int & 1) != 0 gripper_2_closed = (ret_int & 2) != 0 @@ -693,11 +693,11 @@ async def start_freedrive_mode( PFAxis.WRIST, PFAxis.RAIL, ]: - await self._driver.send_command(f"freemode {axis}") + await self.driver.send_command(f"freemode {axis}") async def stop_freedrive_mode(self, backend_params=None) -> None: """Exit freedrive mode for all axes.""" - await self._driver.send_command("freemode -1") + await self.driver.send_command("freemode -1") # -- internal pick/place helpers ------------------------------------------- @@ -717,7 +717,7 @@ async def _pick_plate_j(self, joint_position: Dict[int, float], access: AccessPa await self._set_joint_angles(self.location_index, joint_position) await self._set_grip_detail(access) horizontal_compliance_int = 1 if self.horizontal_compliance else 0 - ret_code = await self._driver.send_command( + ret_code = await self.driver.send_command( f"pickplate {self.location_index} {horizontal_compliance_int} {self.horizontal_compliance_torque}" ) if ret_code == "0": @@ -728,7 +728,7 @@ async def _place_plate_j(self, joint_position: Dict[int, float], access: AccessP await self._set_joint_angles(self.location_index, joint_position) await self._set_grip_detail(access) horizontal_compliance_int = 1 if self.horizontal_compliance else 0 - await self._driver.send_command( + await self.driver.send_command( f"placeplate {self.location_index} {horizontal_compliance_int} {self.horizontal_compliance_torque}" ) @@ -763,7 +763,7 @@ async def _pick_plate_c( orientation_int |= 0x1000 # GPL_Single: restrict wrist to ±180° await self._set_location_config(self.location_index, orientation_int) horizontal_compliance_int = 1 if self.horizontal_compliance else 0 - ret_code = await self._driver.send_command( + ret_code = await self.driver.send_command( f"pickplate {self.location_index} {horizontal_compliance_int} {self.horizontal_compliance_torque}" ) if ret_code == "0": @@ -782,7 +782,7 @@ async def _place_plate_c( orientation_int |= 0x1000 # GPL_Single: restrict wrist to ±180° await self._set_location_config(self.location_index, orientation_int) horizontal_compliance_int = 1 if self.horizontal_compliance else 0 - await self._driver.send_command( + await self.driver.send_command( f"placeplate {self.location_index} {horizontal_compliance_int} {self.horizontal_compliance_torque}" ) @@ -796,11 +796,11 @@ async def _set_grip_detail(self, access: AccessPattern): access: Access pattern (VerticalAccess or HorizontalAccess) defining how to approach and retract from the location. """ if isinstance(access, VerticalAccess): - await self._driver.send_command( + await self.driver.send_command( f"StationType {self.location_index} 1 0 {access.clearance_mm} 0 {access.gripper_offset_mm}" ) elif isinstance(access, HorizontalAccess): - await self._driver.send_command( + await self.driver.send_command( f"StationType {self.location_index} 0 0 {access.clearance_mm} {access.lift_height_mm} {access.gripper_offset_mm}" ) else: @@ -814,7 +814,7 @@ async def request_base(self) -> tuple[float, float, float, float]: Returns: A tuple containing (x_offset, y_offset, z_offset, z_rotation) """ - data = await self._driver.send_command("base") + data = await self.driver.send_command("base") parts = data.split() if len(parts) != 4: raise PreciseFlexError(-1, "Unexpected response format from base command.") @@ -835,7 +835,7 @@ async def set_base( The robot must be attached to set the base. Setting the base pauses any robot motion in progress. """ - await self._driver.send_command(f"base {x_offset} {y_offset} {z_offset} {z_rotation}") + await self.driver.send_command(f"base {x_offset} {y_offset} {z_offset} {z_rotation}") async def request_monitor_speed(self) -> int: """Get the global system (monitor) speed. @@ -843,7 +843,7 @@ async def request_monitor_speed(self) -> int: Returns: Current monitor speed as a percentage (1-100) """ - response = await self._driver.send_command("mspeed") + response = await self.driver.send_command("mspeed") return int(response) async def set_monitor_speed(self, speed_percent: int) -> None: @@ -857,7 +857,7 @@ async def set_monitor_speed(self, speed_percent: int) -> None: """ if not (1 <= speed_percent <= 100): raise ValueError("Speed percent must be between 1 and 100") - await self._driver.send_command(f"mspeed {speed_percent}") + await self.driver.send_command(f"mspeed {speed_percent}") async def nop(self) -> None: """No operation command. @@ -865,7 +865,7 @@ async def nop(self) -> None: Does nothing except return the standard reply. Can be used to see if the link is active or to check for exceptions. """ - await self._driver.send_command("nop") + await self.driver.send_command("nop") async def request_payload(self) -> int: """Get the payload percent value for the current robot. @@ -873,7 +873,7 @@ async def request_payload(self) -> int: Returns: Current payload as a percentage of maximum (0-100) """ - response = await self._driver.send_command("payload") + response = await self.driver.send_command("payload") return int(response) async def set_payload(self, payload_percent: int) -> None: @@ -890,7 +890,7 @@ async def set_payload(self, payload_percent: int) -> None: """ if not (0 <= payload_percent <= 100): raise ValueError("Payload percent must be between 0 and 100") - await self._driver.send_command(f"payload {payload_percent}") + await self.driver.send_command(f"payload {payload_percent}") async def set_parameter( self, @@ -915,18 +915,18 @@ async def set_parameter( """ if unit_number is not None and sub_unit is not None and array_index is not None: if isinstance(value, str): - await self._driver.send_command( + await self.driver.send_command( f'pc {data_id} {unit_number} {sub_unit} {array_index} "{value}"' ) else: - await self._driver.send_command( + await self.driver.send_command( f"pc {data_id} {unit_number} {sub_unit} {array_index} {value}" ) else: if isinstance(value, str): - await self._driver.send_command(f'pc {data_id} "{value}"') + await self.driver.send_command(f'pc {data_id} "{value}"') else: - await self._driver.send_command(f"pc {data_id} {value}") + await self.driver.send_command(f"pc {data_id} {value}") async def request_parameter( self, @@ -949,15 +949,15 @@ async def request_parameter( if unit_number is not None: if sub_unit is not None: if array_index is not None: - response = await self._driver.send_command( + response = await self.driver.send_command( f"pd {data_id} {unit_number} {sub_unit} {array_index}" ) else: - response = await self._driver.send_command(f"pd {data_id} {unit_number} {sub_unit}") + response = await self.driver.send_command(f"pd {data_id} {unit_number} {sub_unit}") else: - response = await self._driver.send_command(f"pd {data_id} {unit_number}") + response = await self.driver.send_command(f"pd {data_id} {unit_number}") else: - response = await self._driver.send_command(f"pd {data_id}") + response = await self.driver.send_command(f"pd {data_id}") return response async def reset(self, robot_number: int) -> None: @@ -974,7 +974,7 @@ async def reset(self, robot_number: int) -> None: """ if robot_number <= 0: raise ValueError("Robot number must be greater than zero") - await self._driver.send_command(f"reset {robot_number}") + await self.driver.send_command(f"reset {robot_number}") async def request_selected_robot(self) -> int: """Get the number of the currently selected robot. @@ -982,7 +982,7 @@ async def request_selected_robot(self) -> int: Returns: The number of the currently selected robot. """ - response = await self._driver.send_command("selectRobot") + response = await self.driver.send_command("selectRobot") return int(response) async def select_robot(self, robot_number: int) -> None: @@ -995,7 +995,7 @@ async def select_robot(self, robot_number: int) -> None: Args: robot_number: The new robot to be connected to this thread (1 to N_ROB) or 0 for none. """ - await self._driver.send_command(f"selectRobot {robot_number}") + await self.driver.send_command(f"selectRobot {robot_number}") async def request_signal(self, signal_number: int) -> int: """Get the value of the specified digital input or output signal. @@ -1006,7 +1006,7 @@ async def request_signal(self, signal_number: int) -> int: Returns: The current signal value. """ - response = await self._driver.send_command(f"sig {signal_number}") + response = await self.driver.send_command(f"sig {signal_number}") sig_id, sig_val = response.split() return int(sig_val) @@ -1017,7 +1017,7 @@ async def set_signal(self, signal_number: int, value: int) -> None: signal_number: The number of the digital signal to set. value: The signal value to set. 0 = off, non-zero = on. """ - await self._driver.send_command(f"sig {signal_number} {value}") + await self.driver.send_command(f"sig {signal_number} {value}") async def request_system_state(self) -> int: """Get the global system state code. @@ -1025,7 +1025,7 @@ async def request_system_state(self) -> int: Returns: The global system state code. Please see documentation for DataID 234. """ - response = await self._driver.send_command("sysState") + response = await self.driver.send_command("sysState") return int(response) async def request_tool_transformation_values(self) -> tuple[float, float, float, float, float, float]: @@ -1034,7 +1034,7 @@ async def request_tool_transformation_values(self) -> tuple[float, float, float, Returns: A tuple containing (X, Y, Z, yaw, pitch, roll) for the tool transformation. """ - data = await self._driver.send_command("tool") + data = await self.driver.send_command("tool") if data.startswith("tool: "): data = data[6:] parts = data.split() @@ -1058,7 +1058,7 @@ async def set_tool_transformation_values( pitch: Tool pitch rotation. roll: Tool roll rotation. """ - await self._driver.send_command(f"tool {x} {y} {z} {yaw} {pitch} {roll}") + await self.driver.send_command(f"tool {x} {y} {z} {yaw} {pitch} {roll}") async def request_version(self) -> str: """Get the current version of TCS and any installed plug-ins. @@ -1066,7 +1066,7 @@ async def request_version(self) -> str: Returns: str: The current version information. """ - return await self._driver.send_command("version") + return await self.driver.send_command("version") # -- LOCATION COMMANDS ----------------------------------------------------- @@ -1077,7 +1077,7 @@ async def _set_joint_angles( ) -> None: """Set joint angles for stored location, handling rail configuration.""" if self._has_rail: - await self._driver.send_command( + await self.driver.send_command( f"locAngles {location_index} " f"{joint_position[PFAxis.RAIL]} " f"{joint_position[PFAxis.BASE]} " @@ -1087,7 +1087,7 @@ async def _set_joint_angles( f"{joint_position[PFAxis.GRIPPER]}" ) else: - await self._driver.send_command( + await self.driver.send_command( f"locAngles {location_index} " f"{joint_position[PFAxis.BASE]} " f"{joint_position[PFAxis.SHOULDER]} " @@ -1107,7 +1107,7 @@ async def _set_location_xyz( location_index: The station index, from 1 to N_LOC. cartesian_position: The Cartesian position to set. """ - await self._driver.send_command( + await self.driver.send_command( f"locXyz {location_index} " f"{cartesian_position.location.x} " f"{cartesian_position.location.y} " @@ -1154,7 +1154,7 @@ async def _set_location_config(self, location_index: int, config_value: int) -> raise ValueError("Cannot specify both GPL_Above and GPL_Below") if (config_value & GPL_FLIP) and (config_value & GPL_NOFLIP): raise ValueError("Cannot specify both GPL_Flip and GPL_NoFlip") - await self._driver.send_command(f"locConfig {location_index} {config_value}") + await self.driver.send_command(f"locConfig {location_index} {config_value}") async def dest_c(self, arg1: int = 0) -> tuple[float, float, float, float, float, float, int]: """Get the destination or current Cartesian location of the robot. @@ -1170,9 +1170,9 @@ async def dest_c(self, arg1: int = 0) -> tuple[float, float, float, float, float If arg1 = 0 and robot is not moving, returns the current location. """ if arg1 == 0: - data = await self._driver.send_command("destC") + data = await self.driver.send_command("destC") else: - data = await self._driver.send_command(f"destC {arg1}") + data = await self.driver.send_command(f"destC {arg1}") parts = data.split() if len(parts) != 7: raise PreciseFlexError(-1, "Unexpected response format from destC command.") @@ -1194,9 +1194,9 @@ async def dest_j(self, arg1: int = 0) -> Dict[int, float]: If arg1 = 0 and robot is not moving, returns the current joint positions. """ if arg1 == 0: - data = await self._driver.send_command("destJ") + data = await self.driver.send_command("destJ") else: - data = await self._driver.send_command(f"destJ {arg1}") + data = await self.driver.send_command(f"destJ {arg1}") parts = data.split() if not parts: raise PreciseFlexError(-1, "Unexpected response format from destJ command.") @@ -1210,7 +1210,7 @@ async def here_j(self, location_index: int) -> None: Args: location_index: The station index, from 1 to N_LOC. """ - await self._driver.send_command(f"hereJ {location_index}") + await self.driver.send_command(f"hereJ {location_index}") async def here_c(self, location_index: int) -> None: """Record the current position of the selected robot into the specified Location as Cartesian. @@ -1221,7 +1221,7 @@ async def here_c(self, location_index: int) -> None: Args: location_index: The station index, from 1 to N_LOC. """ - await self._driver.send_command(f"hereC {location_index}") + await self.driver.send_command(f"hereC {location_index}") # -- PROFILE COMMANDS ------------------------------------------------------ @@ -1234,7 +1234,7 @@ async def request_profile_speed(self, profile_index: int) -> float: Returns: float: The current speed as a percentage. 100 = full speed. """ - response = await self._driver.send_command(f"Speed {profile_index}") + response = await self.driver.send_command(f"Speed {profile_index}") profile, speed = response.split() return float(speed) @@ -1246,7 +1246,7 @@ async def set_profile_speed(self, profile_index: int, speed_percent: float) -> N speed_percent: The new speed as a percentage. 100 = full speed. Values > 100 may be accepted depending on system configuration. """ - await self._driver.send_command(f"Speed {profile_index} {speed_percent}") + await self.driver.send_command(f"Speed {profile_index} {speed_percent}") async def request_profile_speed2(self, profile_index: int) -> float: """Get the speed2 property of the specified profile. @@ -1257,7 +1257,7 @@ async def request_profile_speed2(self, profile_index: int) -> float: Returns: float: The current speed2 as a percentage. Used for Cartesian moves. """ - response = await self._driver.send_command(f"Speed2 {profile_index}") + response = await self.driver.send_command(f"Speed2 {profile_index}") profile, speed2 = response.split() return float(speed2) @@ -1269,7 +1269,7 @@ async def set_profile_speed2(self, profile_index: int, speed2_percent: float) -> speed2_percent: The new speed2 as a percentage. 100 = full speed. Used for Cartesian moves. Normally set to 0. """ - await self._driver.send_command(f"Speed2 {profile_index} {speed2_percent}") + await self.driver.send_command(f"Speed2 {profile_index} {speed2_percent}") async def request_profile_accel(self, profile_index: int) -> float: """Get the acceleration property of the specified profile. @@ -1280,7 +1280,7 @@ async def request_profile_accel(self, profile_index: int) -> float: Returns: float: The current acceleration as a percentage. 100 = maximum acceleration. """ - response = await self._driver.send_command(f"Accel {profile_index}") + response = await self.driver.send_command(f"Accel {profile_index}") profile, accel = response.split() return float(accel) @@ -1292,7 +1292,7 @@ async def set_profile_accel(self, profile_index: int, accel_percent: float) -> N accel_percent: The new acceleration as a percentage. 100 = maximum acceleration. Maximum value depends on system configuration. """ - await self._driver.send_command(f"Accel {profile_index} {accel_percent}") + await self.driver.send_command(f"Accel {profile_index} {accel_percent}") async def request_profile_accel_ramp(self, profile_index: int) -> float: """Get the acceleration ramp property of the specified profile. @@ -1303,7 +1303,7 @@ async def request_profile_accel_ramp(self, profile_index: int) -> float: Returns: float: The current acceleration ramp time in seconds. """ - response = await self._driver.send_command(f"AccRamp {profile_index}") + response = await self.driver.send_command(f"AccRamp {profile_index}") profile, accel_ramp = response.split() return float(accel_ramp) @@ -1314,7 +1314,7 @@ async def set_profile_accel_ramp(self, profile_index: int, accel_ramp_seconds: f profile_index: The profile index to modify. accel_ramp_seconds: The new acceleration ramp time in seconds. """ - await self._driver.send_command(f"AccRamp {profile_index} {accel_ramp_seconds}") + await self.driver.send_command(f"AccRamp {profile_index} {accel_ramp_seconds}") async def request_profile_decel(self, profile_index: int) -> float: """Get the deceleration property of the specified profile. @@ -1325,7 +1325,7 @@ async def request_profile_decel(self, profile_index: int) -> float: Returns: float: The current deceleration as a percentage. 100 = maximum deceleration. """ - response = await self._driver.send_command(f"Decel {profile_index}") + response = await self.driver.send_command(f"Decel {profile_index}") profile, decel = response.split() return float(decel) @@ -1337,7 +1337,7 @@ async def set_profile_decel(self, profile_index: int, decel_percent: float) -> N decel_percent: The new deceleration as a percentage. 100 = maximum deceleration. Maximum value depends on system configuration. """ - await self._driver.send_command(f"Decel {profile_index} {decel_percent}") + await self.driver.send_command(f"Decel {profile_index} {decel_percent}") async def request_profile_decel_ramp(self, profile_index: int) -> float: """Get the deceleration ramp property of the specified profile. @@ -1348,7 +1348,7 @@ async def request_profile_decel_ramp(self, profile_index: int) -> float: Returns: float: The current deceleration ramp time in seconds. """ - response = await self._driver.send_command(f"DecRamp {profile_index}") + response = await self.driver.send_command(f"DecRamp {profile_index}") profile, decel_ramp = response.split() return float(decel_ramp) @@ -1359,7 +1359,7 @@ async def set_profile_decel_ramp(self, profile_index: int, decel_ramp_seconds: f profile_index: The profile index to modify. decel_ramp_seconds: The new deceleration ramp time in seconds. """ - await self._driver.send_command(f"DecRamp {profile_index} {decel_ramp_seconds}") + await self.driver.send_command(f"DecRamp {profile_index} {decel_ramp_seconds}") async def request_profile_in_range(self, profile_index: int) -> float: """Get the InRange property of the specified profile. @@ -1373,7 +1373,7 @@ async def request_profile_in_range(self, profile_index: int) -> float: 0 = always stop but do not check end point error > 0 = wait until close to end point (larger numbers mean less position error allowed) """ - response = await self._driver.send_command(f"InRange {profile_index}") + response = await self.driver.send_command(f"InRange {profile_index}") profile, in_range = response.split() return float(in_range) @@ -1392,7 +1392,7 @@ async def set_profile_in_range(self, profile_index: int, in_range_value: float) """ if not (-1 <= in_range_value <= 100): raise ValueError("InRange value must be between -1 and 100") - await self._driver.send_command(f"InRange {profile_index} {in_range_value}") + await self.driver.send_command(f"InRange {profile_index} {in_range_value}") async def request_profile_straight(self, profile_index: int) -> bool: """Get the Straight property of the specified profile. @@ -1405,7 +1405,7 @@ async def request_profile_straight(self, profile_index: int) -> bool: True = follow a straight-line path False = follow a joint-based path (coordinated axes movement) """ - response = await self._driver.send_command(f"Straight {profile_index}") + response = await self.driver.send_command(f"Straight {profile_index}") profile, straight = response.split() return straight == "True" @@ -1422,7 +1422,7 @@ async def set_profile_straight(self, profile_index: int, straight_mode: bool) -> ValueError: If straight_mode is not True or False. """ straight_int = 1 if straight_mode else 0 - await self._driver.send_command(f"Straight {profile_index} {straight_int}") + await self.driver.send_command(f"Straight {profile_index} {straight_int}") async def set_motion_profile_values( self, @@ -1465,7 +1465,7 @@ async def set_motion_profile_values( if not (-1 <= in_range <= 100): raise ValueError("InRange must be between -1 and 100.") straight_int = -1 if straight else 0 - await self._driver.send_command( + await self.driver.send_command( f"Profile {profile} {speed} {speed2} {acceleration} {deceleration} " f"{acceleration_ramp} {deceleration_ramp} {in_range} {straight_int}" ) @@ -1491,7 +1491,7 @@ async def request_motion_profile_values( - in_range: InRange value (-1 to 100) - straight: True if straight-line path, False if joint-based path """ - data = await self._driver.send_command(f"Profile {profile}") + data = await self.driver.send_command(f"Profile {profile}") parts = data.split(" ") if len(parts) != 9: raise PreciseFlexError(-1, "Unexpected response format from device.") @@ -1516,7 +1516,7 @@ async def _set_rail_position(self, station_id: int, rail_position: float) -> Non station_id: The station index. rail_position: The rail position in mm. """ - await self._driver.send_command(f"Rail {station_id} {rail_position}") + await self.driver.send_command(f"Rail {station_id} {rail_position}") async def _move_rail(self, station_id: Optional[int] = None, mode: int = 1) -> None: """Move the rail to the position stored at the specified station. @@ -1526,9 +1526,9 @@ async def _move_rail(self, station_id: Optional[int] = None, mode: int = 1) -> N mode: Motion mode (0 = normal). """ if station_id is not None: - await self._driver.send_command(f"MoveRail {station_id} {mode}") + await self.driver.send_command(f"MoveRail {station_id} {mode}") else: - await self._driver.send_command(f"MoveRail {mode}") + await self.driver.send_command(f"MoveRail {mode}") # -- MOTION COMMANDS ------------------------------------------------------- @@ -1542,7 +1542,7 @@ async def _move_to_stored_location(self, location_index: int, profile_index: int Note: Requires that the robot be attached. """ - await self._driver.send_command(f"move {location_index} {profile_index}") + await self.driver.send_command(f"move {location_index} {profile_index}") async def _move_to_stored_location_appro(self, location_index: int, profile_index: int) -> None: """Approach the location specified by the station index using the specified profile. @@ -1556,7 +1556,7 @@ async def _move_to_stored_location_appro(self, location_index: int, profile_inde Note: Requires that the robot be attached. """ - await self._driver.send_command(f"moveAppro {location_index} {profile_index}") + await self.driver.send_command(f"moveAppro {location_index} {profile_index}") async def _move_c( self, @@ -1585,7 +1585,7 @@ async def _move_c( config_int = self._convert_orientation_str_to_int(cartesian_coords.orientation) config_int |= 0x1000 cmd += f"{config_int}" - await self._driver.send_command(cmd) + await self.driver.send_command(cmd) async def _move_j(self, profile_index: int, joint_coords: Dict[int, float]) -> None: """Move the robot using joint coordinates, handling rail configuration.""" @@ -1606,7 +1606,7 @@ async def _move_j(self, profile_index: int, joint_coords: Dict[int, float]) -> N f"{joint_coords[PFAxis.WRIST]} " f"{joint_coords[PFAxis.GRIPPER]}" ) - await self._driver.send_command(f"moveJ {profile_index} {angles_str}") + await self.driver.send_command(f"moveJ {profile_index} {angles_str}") async def release_brake(self, axis: int) -> None: """Release the axis brake. @@ -1618,7 +1618,7 @@ async def release_brake(self, axis: int) -> None: Args: axis: The number of the axis whose brake should be released. """ - await self._driver.send_command(f"releaseBrake {axis}") + await self.driver.send_command(f"releaseBrake {axis}") async def set_brake(self, axis: int) -> None: """Set the axis brake. @@ -1629,7 +1629,7 @@ async def set_brake(self, axis: int) -> None: Args: axis: The number of the axis whose brake should be set. """ - await self._driver.send_command(f"setBrake {axis}") + await self.driver.send_command(f"setBrake {axis}") async def zero_torque(self, enable: bool, axis_mask: int = 1) -> None: """Sets or clears zero torque mode for the selected robot. @@ -1642,9 +1642,9 @@ async def zero_torque(self, enable: bool, axis_mask: int = 1) -> None: """ if enable: assert axis_mask > 0, "axis_mask must be greater than 0" - await self._driver.send_command(f"zeroTorque 1 {axis_mask}") + await self.driver.send_command(f"zeroTorque 1 {axis_mask}") else: - await self._driver.send_command("zeroTorque 0") + await self.driver.send_command("zeroTorque 0") # -- PAROBOT COMMANDS ------------------------------------------------------ @@ -1661,7 +1661,7 @@ async def change_config(self, grip_mode: int = 0) -> None: 1 = open gripper 2 = close gripper """ - await self._driver.send_command(f"ChangeConfig {grip_mode}") + await self.driver.send_command(f"ChangeConfig {grip_mode}") async def change_config2(self, grip_mode: int = 0) -> None: """Change Robot configuration from Righty to Lefty or vice versa using algorithm. @@ -1676,7 +1676,7 @@ async def change_config2(self, grip_mode: int = 0) -> None: 1 = open gripper 2 = close gripper """ - await self._driver.send_command(f"ChangeConfig2 {grip_mode}") + await self.driver.send_command(f"ChangeConfig2 {grip_mode}") async def _request_grasp_data(self) -> tuple[float, float, float]: """Get the data to be used for the next force-controlled PickPlate command grip operation. @@ -1684,7 +1684,7 @@ async def _request_grasp_data(self) -> tuple[float, float, float]: Returns: A tuple containing (plate_width_mm, finger_speed_percent, grasp_force) """ - data = await self._driver.send_command("GraspData") + data = await self.driver.send_command("GraspData") parts = data.split() if len(parts) != 3: raise PreciseFlexError(-1, "Unexpected response format from GraspData command.") @@ -1704,7 +1704,7 @@ async def _set_grasp_data( A positive value indicates the fingers must close to grasp. A negative value indicates the fingers must open to grasp. """ - await self._driver.send_command(f"GraspData {plate_width} {finger_speed_percent} {grasp_force}") + await self.driver.send_command(f"GraspData {plate_width} {finger_speed_percent} {grasp_force}") async def _request_grip_close_pos(self) -> float: """Get the gripper close position for the servoed gripper. @@ -1712,7 +1712,7 @@ async def _request_grip_close_pos(self) -> float: Returns: float: The current gripper close position. """ - data = await self._driver.send_command("GripClosePos") + data = await self.driver.send_command("GripClosePos") return float(data) async def _set_grip_close_pos(self, close_position: float) -> None: @@ -1723,7 +1723,7 @@ async def _set_grip_close_pos(self, close_position: float) -> None: Args: close_position: The new gripper close position. """ - await self._driver.send_command(f"GripClosePos {close_position}") + await self.driver.send_command(f"GripClosePos {close_position}") async def _request_grip_open_pos(self) -> float: """Get the gripper open position for the servoed gripper. @@ -1731,7 +1731,7 @@ async def _request_grip_open_pos(self) -> float: Returns: float: The current gripper open position. """ - data = await self._driver.send_command("GripOpenPos") + data = await self.driver.send_command("GripOpenPos") return float(data) async def _set_grip_open_pos(self, open_position: float) -> None: @@ -1740,7 +1740,7 @@ async def _set_grip_open_pos(self, open_position: float) -> None: Args: open_position: The new gripper open position. """ - await self._driver.send_command(f"GripOpenPos {open_position}") + await self.driver.send_command(f"GripOpenPos {open_position}") # -- parsing helpers ------------------------------------------------------- @@ -1798,7 +1798,7 @@ def __init__( ) -> None: driver = PreciseFlexDriver(host=host, port=port, timeout=timeout) super().__init__(driver=driver) - self._driver: PreciseFlexDriver = driver + self.driver: PreciseFlexDriver = driver backend = PreciseFlexArmBackend(driver=driver, has_rail=has_rail) self.reference = Resource(name="PreciseFlex400", size_x=200, size_y=200, size_z=200) self.arm = OrientableArm(backend=backend, reference_resource=self.reference) diff --git a/pylabrobot/bulk_dispensers/__init__.py b/pylabrobot/bulk_dispensers/__init__.py new file mode 100644 index 00000000000..e224ad00513 --- /dev/null +++ b/pylabrobot/bulk_dispensers/__init__.py @@ -0,0 +1,3 @@ +from pylabrobot.bulk_dispensers.backend import BulkDispenserBackend +from pylabrobot.bulk_dispensers.bulk_dispenser import BulkDispenser +from pylabrobot.bulk_dispensers.chatterbox import BulkDispenserChatterboxBackend diff --git a/pylabrobot/bulk_dispensers/backend.py b/pylabrobot/bulk_dispensers/backend.py new file mode 100644 index 00000000000..34a3ed97468 --- /dev/null +++ b/pylabrobot/bulk_dispensers/backend.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod + +from pylabrobot.legacy.machines.backend import MachineBackend + + +class BulkDispenserBackend(MachineBackend, metaclass=ABCMeta): + """Abstract class for bulk dispenser backends. + + Volumes are specified in microliters (float). Concrete backends are responsible + for converting to instrument-specific units. + """ + + @abstractmethod + async def dispense(self) -> None: + pass + + @abstractmethod + async def prime(self, volume: float) -> None: + pass + + @abstractmethod + async def empty(self, volume: float) -> None: + pass + + @abstractmethod + async def shake(self, time: float, distance: int, speed: int) -> None: + """Shake the plate. + + Args: + time: Shake duration in seconds. + distance: Shake distance in mm (1-5). + speed: Shake frequency in Hz (1-20). + """ + + @abstractmethod + async def move_plate_out(self) -> None: + pass + + @abstractmethod + async def set_plate_type(self, plate_type: int) -> None: + pass + + @abstractmethod + async def set_cassette_type(self, cassette_type: int) -> None: + pass + + @abstractmethod + async def set_column_volume(self, column: int, volume: float) -> None: + """Set dispense volume for a column. + + Args: + column: Column number (0 = all columns). + volume: Volume in microliters. + """ + + @abstractmethod + async def set_dispensing_height(self, height: int) -> None: + """Set dispensing height. + + Args: + height: Height in 1/100 mm (500-5500). + """ + + @abstractmethod + async def set_pump_speed(self, speed: int) -> None: + """Set pump speed as percentage of cassette range. + + Args: + speed: Speed percentage (1-100). + """ + + @abstractmethod + async def set_dispensing_order(self, order: int) -> None: + """Set dispensing order. + + Args: + order: 0 = row-wise, 1 = column-wise. + """ + + @abstractmethod + async def abort(self) -> None: + pass diff --git a/pylabrobot/bulk_dispensers/bulk_dispenser.py b/pylabrobot/bulk_dispensers/bulk_dispenser.py new file mode 100644 index 00000000000..db834747e4e --- /dev/null +++ b/pylabrobot/bulk_dispensers/bulk_dispenser.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from pylabrobot.bulk_dispensers.backend import BulkDispenserBackend +from pylabrobot.legacy.machines.machine import Machine, need_setup_finished + + +class BulkDispenser(Machine): + """Frontend for bulk reagent dispensers.""" + + def __init__(self, backend: BulkDispenserBackend) -> None: + super().__init__(backend=backend) + self.backend: BulkDispenserBackend = backend + + @need_setup_finished + async def dispense(self, **backend_kwargs) -> None: + await self.backend.dispense(**backend_kwargs) + + @need_setup_finished + async def prime(self, volume: float, **backend_kwargs) -> None: + await self.backend.prime(volume=volume, **backend_kwargs) + + @need_setup_finished + async def empty(self, volume: float, **backend_kwargs) -> None: + await self.backend.empty(volume=volume, **backend_kwargs) + + @need_setup_finished + async def shake(self, time: float, distance: int, speed: int, **backend_kwargs) -> None: + await self.backend.shake(time=time, distance=distance, speed=speed, **backend_kwargs) + + @need_setup_finished + async def move_plate_out(self, **backend_kwargs) -> None: + await self.backend.move_plate_out(**backend_kwargs) + + @need_setup_finished + async def set_plate_type(self, plate_type: int, **backend_kwargs) -> None: + await self.backend.set_plate_type(plate_type=plate_type, **backend_kwargs) + + @need_setup_finished + async def set_cassette_type(self, cassette_type: int, **backend_kwargs) -> None: + await self.backend.set_cassette_type(cassette_type=cassette_type, **backend_kwargs) + + @need_setup_finished + async def set_column_volume(self, column: int, volume: float, **backend_kwargs) -> None: + await self.backend.set_column_volume(column=column, volume=volume, **backend_kwargs) + + @need_setup_finished + async def set_dispensing_height(self, height: int, **backend_kwargs) -> None: + await self.backend.set_dispensing_height(height=height, **backend_kwargs) + + @need_setup_finished + async def set_pump_speed(self, speed: int, **backend_kwargs) -> None: + await self.backend.set_pump_speed(speed=speed, **backend_kwargs) + + @need_setup_finished + async def set_dispensing_order(self, order: int, **backend_kwargs) -> None: + await self.backend.set_dispensing_order(order=order, **backend_kwargs) + + @need_setup_finished + async def abort(self, **backend_kwargs) -> None: + await self.backend.abort(**backend_kwargs) diff --git a/pylabrobot/bulk_dispensers/chatterbox.py b/pylabrobot/bulk_dispensers/chatterbox.py new file mode 100644 index 00000000000..eddf02b68c8 --- /dev/null +++ b/pylabrobot/bulk_dispensers/chatterbox.py @@ -0,0 +1,47 @@ +from pylabrobot.bulk_dispensers.backend import BulkDispenserBackend + + +class BulkDispenserChatterboxBackend(BulkDispenserBackend): + """A backend that prints operations for testing without hardware.""" + + async def setup(self) -> None: + print("Setting up bulk dispenser.") + + async def stop(self) -> None: + print("Stopping bulk dispenser.") + + async def dispense(self) -> None: + print("Dispensing.") + + async def prime(self, volume: float) -> None: + print(f"Priming with {volume} uL.") + + async def empty(self, volume: float) -> None: + print(f"Emptying with {volume} uL.") + + async def shake(self, time: float, distance: int, speed: int) -> None: + print(f"Shaking for {time}s, distance={distance}mm, speed={speed}Hz.") + + async def move_plate_out(self) -> None: + print("Moving plate out.") + + async def set_plate_type(self, plate_type: int) -> None: + print(f"Setting plate type to {plate_type}.") + + async def set_cassette_type(self, cassette_type: int) -> None: + print(f"Setting cassette type to {cassette_type}.") + + async def set_column_volume(self, column: int, volume: float) -> None: + print(f"Setting column {column} volume to {volume} uL.") + + async def set_dispensing_height(self, height: int) -> None: + print(f"Setting dispensing height to {height}.") + + async def set_pump_speed(self, speed: int) -> None: + print(f"Setting pump speed to {speed}%.") + + async def set_dispensing_order(self, order: int) -> None: + print(f"Setting dispensing order to {order}.") + + async def abort(self) -> None: + print("Aborting.") diff --git a/pylabrobot/bulk_dispensers/tests/__init__.py b/pylabrobot/bulk_dispensers/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/bulk_dispensers/tests/bulk_dispenser_tests.py b/pylabrobot/bulk_dispensers/tests/bulk_dispenser_tests.py new file mode 100644 index 00000000000..26314145247 --- /dev/null +++ b/pylabrobot/bulk_dispensers/tests/bulk_dispenser_tests.py @@ -0,0 +1,91 @@ +import unittest + +from pylabrobot.bulk_dispensers import ( + BulkDispenser, + BulkDispenserBackend, + BulkDispenserChatterboxBackend, +) + + +class BulkDispenserSetupTests(unittest.IsolatedAsyncioTestCase): + """Test setup/stop lifecycle and need_setup_finished guard.""" + + def setUp(self): + self.backend = BulkDispenserChatterboxBackend() + self.dispenser = BulkDispenser(backend=self.backend) + + async def test_methods_fail_before_setup(self): + with self.assertRaises(RuntimeError): + await self.dispenser.dispense() + with self.assertRaises(RuntimeError): + await self.dispenser.prime(volume=100.0) + with self.assertRaises(RuntimeError): + await self.dispenser.abort() + + async def test_setup_and_stop(self): + await self.dispenser.setup() + self.assertTrue(self.dispenser.setup_finished) + await self.dispenser.stop() + self.assertFalse(self.dispenser.setup_finished) + + async def test_context_manager(self): + async with BulkDispenser(backend=BulkDispenserChatterboxBackend()) as d: + self.assertTrue(d.setup_finished) + self.assertFalse(d.setup_finished) + + +class BulkDispenserDelegationTests(unittest.IsolatedAsyncioTestCase): + """Test that frontend methods delegate to the backend.""" + + async def asyncSetUp(self): + self.backend = unittest.mock.MagicMock(spec=BulkDispenserBackend) + self.dispenser = BulkDispenser(backend=self.backend) + self.dispenser._setup_finished = True + + async def test_dispense(self): + await self.dispenser.dispense() + self.backend.dispense.assert_awaited_once() + + async def test_prime(self): + await self.dispenser.prime(volume=50.0) + self.backend.prime.assert_awaited_once_with(volume=50.0) + + async def test_empty(self): + await self.dispenser.empty(volume=100.0) + self.backend.empty.assert_awaited_once_with(volume=100.0) + + async def test_shake(self): + await self.dispenser.shake(time=5.0, distance=3, speed=10) + self.backend.shake.assert_awaited_once_with(time=5.0, distance=3, speed=10) + + async def test_move_plate_out(self): + await self.dispenser.move_plate_out() + self.backend.move_plate_out.assert_awaited_once() + + async def test_set_plate_type(self): + await self.dispenser.set_plate_type(plate_type=3) + self.backend.set_plate_type.assert_awaited_once_with(plate_type=3) + + async def test_set_cassette_type(self): + await self.dispenser.set_cassette_type(cassette_type=1) + self.backend.set_cassette_type.assert_awaited_once_with(cassette_type=1) + + async def test_set_column_volume(self): + await self.dispenser.set_column_volume(column=0, volume=25.0) + self.backend.set_column_volume.assert_awaited_once_with(column=0, volume=25.0) + + async def test_set_dispensing_height(self): + await self.dispenser.set_dispensing_height(height=2500) + self.backend.set_dispensing_height.assert_awaited_once_with(height=2500) + + async def test_set_pump_speed(self): + await self.dispenser.set_pump_speed(speed=50) + self.backend.set_pump_speed.assert_awaited_once_with(speed=50) + + async def test_set_dispensing_order(self): + await self.dispenser.set_dispensing_order(order=1) + self.backend.set_dispensing_order.assert_awaited_once_with(order=1) + + async def test_abort(self): + await self.dispenser.abort() + self.backend.abort.assert_awaited_once() diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/__init__.py b/pylabrobot/bulk_dispensers/thermo_scientific/__init__.py new file mode 100644 index 00000000000..96f12e46471 --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/__init__.py @@ -0,0 +1,13 @@ +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi import ( + CassetteType, + DispensingOrder, + EmptyMode, + MultidropCombiBackend, + MultidropCombiCommunicationError, + MultidropCombiError, + MultidropCombiInstrumentError, + PrimeMode, + plate_to_pla_params, + plate_to_type_index, + plate_well_count, +) diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/__init__.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/__init__.py new file mode 100644 index 00000000000..0d4ac7fe73b --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/__init__.py @@ -0,0 +1,19 @@ +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.backend import ( + MultidropCombiBackend, +) +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.enums import ( + CassetteType, + DispensingOrder, + EmptyMode, + PrimeMode, +) +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.errors import ( + MultidropCombiCommunicationError, + MultidropCombiError, + MultidropCombiInstrumentError, +) +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.helpers import ( + plate_to_pla_params, + plate_to_type_index, + plate_well_count, +) diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/actions.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/actions.py new file mode 100644 index 00000000000..d0bc885d3bb --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/actions.py @@ -0,0 +1,30 @@ +"""Control operations mixin for the Multidrop Combi.""" + +from __future__ import annotations + +from typing import Optional + +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.errors import ( + MultidropCombiCommunicationError, +) +from pylabrobot.io.serial import Serial + + +class MultidropCombiActionsMixin: + """Mixin providing control operations for the Multidrop Combi.""" + + io: Optional[Serial] + + async def abort(self) -> None: + """Send ESC character to abort the current operation.""" + if self.io is None: + raise MultidropCombiCommunicationError("Not connected to instrument", operation="abort") + await self.io.write(b"\x1b") + + async def restart(self) -> None: + """Restart the instrument (equivalent to power cycle).""" + await self._send_command("RST", timeout=10.0) # type: ignore[attr-defined] + + async def acknowledge_error(self) -> None: + """Clear instrument error state.""" + await self._send_command("EAK", timeout=5.0) # type: ignore[attr-defined] diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/backend.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/backend.py new file mode 100644 index 00000000000..fe373c6f96d --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/backend.py @@ -0,0 +1,123 @@ +"""Composed backend for the Thermo Scientific Multidrop Combi.""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Optional + +from pylabrobot.bulk_dispensers.backend import BulkDispenserBackend +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.actions import ( + MultidropCombiActionsMixin, +) +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.commands import ( + MultidropCombiCommandsMixin, +) +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.communication import ( + MultidropCombiCommunicationMixin, +) +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.queries import ( + MultidropCombiQueriesMixin, +) +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + + +class MultidropCombiBackend( + MultidropCombiCommunicationMixin, + MultidropCombiQueriesMixin, + MultidropCombiActionsMixin, + MultidropCombiCommandsMixin, + BulkDispenserBackend, +): + """Backend for the Thermo Scientific Multidrop Combi reagent dispenser. + + Communication is via RS232/USB serial at 9600 baud, 8N1. + + Args: + port: Serial port (e.g. "COM3", "/dev/ttyUSB0"). If None, auto-detected by VID/PID. + timeout: Default serial read timeout in seconds. + """ + + def __init__( + self, + port: str | None = None, + timeout: float = 30.0, + ) -> None: + super().__init__() + self._port = port + self.timeout = timeout + self.io: Optional[Serial] = None + self._command_lock: Optional[asyncio.Lock] = None + self._instrument_name: str = "" + self._firmware_version: str = "" + self._serial_number: str = "" + + async def setup(self) -> None: + self._command_lock = asyncio.Lock() + + # When port is specified, skip VID/PID discovery (the Multidrop is often + # connected via an RS232-to-USB adapter with a different VID/PID). + if self._port: + self.io = Serial( + human_readable_device_name="Multidrop Combi", + port=self._port, + baudrate=9600, + bytesize=8, + parity="N", + stopbits=1, + timeout=self.timeout, + write_timeout=5, + ) + else: + self.io = Serial( + human_readable_device_name="Multidrop Combi", + vid=0x0AB6, + pid=0x0344, + baudrate=9600, + bytesize=8, + parity="N", + stopbits=1, + timeout=self.timeout, + write_timeout=5, + ) + await self.io.setup() + + # Enable XON/XOFF flow control on the underlying serial port + if self.io._ser is not None: + self.io._ser.xonxoff = True + + await self._drain_stale_data() + + info = await self._enter_remote_mode() + self._instrument_name = info["instrument_name"] + self._firmware_version = info["firmware_version"] + self._serial_number = info["serial_number"] + + logger.info( + "Connected to %s (FW: %s, SN: %s)", + self._instrument_name, + self._firmware_version, + self._serial_number, + ) + + # Clear any pending errors + try: + await self.acknowledge_error() + except Exception: + pass + + async def stop(self) -> None: + await self._exit_remote_mode() + if self.io is not None: + await self.io.stop() + self.io = None + self._command_lock = None + + def serialize(self) -> dict: + return { + **super().serialize(), + "port": self._port, + "timeout": self.timeout, + } diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/commands.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/commands.py new file mode 100644 index 00000000000..7e4fd3de14a --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/commands.py @@ -0,0 +1,251 @@ +"""Operational commands mixin for the Multidrop Combi. + +All volume parameters at the public interface are in microliters (float). +Internally, volumes are converted to the instrument's native 1/10 uL units. +""" + +from __future__ import annotations + +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.enums import ( + EmptyMode, + PrimeMode, +) + +# Per-command timeout constants (seconds) +COMMAND_TIMEOUTS = { + "SPL": 5.0, + "SCT": 5.0, + "SCV": 5.0, + "SDH": 5.0, + "SPS": 5.0, + "SDO": 5.0, + "SOF": 5.0, + "SPV": 5.0, + "PLA": 5.0, + "EAK": 5.0, + "POU": 10.0, + "RST": 10.0, + "DIS": 120.0, + "PRI": 60.0, + "EMP": 60.0, + "SHA": 120.0, + "BGN": 120.0, +} + + +def _ul_to_tenths(volume_ul: float) -> int: + """Convert microliters to 1/10 uL integer.""" + return round(volume_ul * 10) + + +class MultidropCombiCommandsMixin: + """Mixin providing operational commands for the Multidrop Combi.""" + + async def dispense(self) -> None: + """Dispense liquid to the plate (DIS command).""" + await self._send_command("DIS", timeout=COMMAND_TIMEOUTS["DIS"]) # type: ignore[attr-defined] + + async def prime(self, volume: float, mode: PrimeMode = PrimeMode.STANDARD) -> None: + """Prime dispenser hoses. + + Args: + volume: Prime volume in microliters. + mode: Prime mode (standard, continuous, stop continuous, calibration). + """ + vol_tenths = _ul_to_tenths(volume) + if vol_tenths < 10 or vol_tenths > 100000: + raise ValueError(f"Prime volume must be 1-10000 uL, got {volume} uL") + cmd = f"PRI {vol_tenths}" + if mode != PrimeMode.STANDARD: + cmd += f" {mode.value}" + timeout = COMMAND_TIMEOUTS["PRI"] + volume / 100.0 + await self._send_command(cmd, timeout=timeout) # type: ignore[attr-defined] + + async def empty(self, volume: float, mode: EmptyMode = EmptyMode.STANDARD) -> None: + """Empty dispenser hoses. + + Args: + volume: Empty volume in microliters. + mode: Empty mode (standard or continuous). + """ + vol_tenths = _ul_to_tenths(volume) + if vol_tenths < 10 or vol_tenths > 100000: + raise ValueError(f"Empty volume must be 1-10000 uL, got {volume} uL") + cmd = f"EMP {vol_tenths}" + if mode != EmptyMode.STANDARD: + cmd += f" {mode.value}" + timeout = COMMAND_TIMEOUTS["EMP"] + volume / 100.0 + await self._send_command(cmd, timeout=timeout) # type: ignore[attr-defined] + + async def shake(self, time: float, distance: int, speed: int) -> None: + """Shake the plate. + + Args: + time: Duration in seconds. + distance: Shake distance in mm (1-5). + speed: Shake frequency in Hz (1-20). + """ + if not 1 <= distance <= 5: + raise ValueError(f"Shake distance must be 1-5 mm, got {distance}") + if not 1 <= speed <= 20: + raise ValueError(f"Shake speed must be 1-20 Hz, got {speed}") + time_hundredths = round(time * 100) + if time_hundredths < 1: + raise ValueError(f"Shake time must be > 0, got {time}s") + timeout = COMMAND_TIMEOUTS["SHA"] + time + await self._send_command( # type: ignore[attr-defined] + f"SHA {time_hundredths} {distance} {speed}", timeout=timeout + ) + + async def move_plate_out(self) -> None: + """Move plate carrier to loading position (POU command).""" + await self._send_command( # type: ignore[attr-defined] + "POU", timeout=COMMAND_TIMEOUTS["POU"] + ) + + async def set_plate_type(self, plate_type: int) -> None: + """Set plate type. + + Args: + plate_type: Plate type index (0-29; 0-9 factory-defined, 10-29 user-defined). + """ + if not 0 <= plate_type <= 29: + raise ValueError(f"Plate type must be 0-29, got {plate_type}") + await self._send_command( # type: ignore[attr-defined] + f"SPL {plate_type}", timeout=COMMAND_TIMEOUTS["SPL"] + ) + + async def set_cassette_type(self, cassette_type: int) -> None: + """Set cassette type. + + Args: + cassette_type: Cassette type (0=Standard, 1=Small, 2-3=User-defined). + """ + if not 0 <= cassette_type <= 3: + raise ValueError(f"Cassette type must be 0-3, got {cassette_type}") + await self._send_command( # type: ignore[attr-defined] + f"SCT {cassette_type}", timeout=COMMAND_TIMEOUTS["SCT"] + ) + + async def set_column_volume(self, column: int, volume: float) -> None: + """Set dispense volume for a column. + + Args: + column: Column number (0 = all columns, 1-48 = specific column). + volume: Volume in microliters. + """ + if not 0 <= column <= 48: + raise ValueError(f"Column must be 0-48, got {column}") + vol_tenths = _ul_to_tenths(volume) + await self._send_command( # type: ignore[attr-defined] + f"SCV {column} {vol_tenths}", timeout=COMMAND_TIMEOUTS["SCV"] + ) + + async def set_dispensing_height(self, height: int) -> None: + """Set dispensing height. + + Args: + height: Height in 1/100 mm (500-5500). + """ + if not 500 <= height <= 5500: + raise ValueError(f"Dispensing height must be 500-5500, got {height}") + await self._send_command( # type: ignore[attr-defined] + f"SDH {height}", timeout=COMMAND_TIMEOUTS["SDH"] + ) + + async def set_pump_speed(self, speed: int) -> None: + """Set pump speed as percentage of cassette range. + + Args: + speed: Speed percentage (1-100). + """ + if not 1 <= speed <= 100: + raise ValueError(f"Pump speed must be 1-100, got {speed}") + await self._send_command( # type: ignore[attr-defined] + f"SPS {speed}", timeout=COMMAND_TIMEOUTS["SPS"] + ) + + async def set_dispensing_order(self, order: int) -> None: + """Set dispensing order. + + Args: + order: 0 = row-wise, 1 = column-wise. + """ + if order not in (0, 1): + raise ValueError(f"Dispensing order must be 0 or 1, got {order}") + await self._send_command( # type: ignore[attr-defined] + f"SDO {order}", timeout=COMMAND_TIMEOUTS["SDO"] + ) + + async def set_dispense_offset(self, x_offset: int, y_offset: int) -> None: + """Set X/Y dispense offset. + + Args: + x_offset: X offset in 1/100 mm (±300). + y_offset: Y offset in 1/100 mm (±300). + """ + if not -300 <= x_offset <= 300: + raise ValueError(f"X offset must be ±300, got {x_offset}") + if not -300 <= y_offset <= 300: + raise ValueError(f"Y offset must be ±300, got {y_offset}") + await self._send_command( # type: ignore[attr-defined] + f"SOF {x_offset} {y_offset}", timeout=COMMAND_TIMEOUTS["SOF"] + ) + + async def set_predispense_volume(self, volume: float) -> None: + """Set predispense volume. + + Args: + volume: Predispense volume in microliters. + """ + vol_tenths = _ul_to_tenths(volume) + if vol_tenths < 10 or vol_tenths > 100000: + raise ValueError(f"Predispense volume must be 1-10000 uL, got {volume} uL") + await self._send_command( # type: ignore[attr-defined] + f"SPV {vol_tenths}", timeout=COMMAND_TIMEOUTS["SPV"] + ) + + async def define_plate( + self, + column_positions: int, + row_positions: int, + rows: int, + columns: int, + height: int, + max_volume: int, + x_offset: int = 0, + y_offset: int = 0, + ) -> None: + """Define a remote plate (PLA command). + + Args: + column_positions: Number of column positions. + row_positions: Number of row positions. + rows: Number of rows. + columns: Number of columns. + height: Plate height in 1/100 mm. + max_volume: Maximum well volume in 1/10 uL. + x_offset: X offset in 1/100 mm. + y_offset: Y offset in 1/100 mm. + """ + await self._send_command( # type: ignore[attr-defined] + f"PLA {column_positions} {row_positions} {rows} {columns} " + f"{height} {max_volume} {x_offset} {y_offset}", + timeout=COMMAND_TIMEOUTS["PLA"], + ) + + async def start_protocol( + self, plate_type: int | None = None, protocol_name: str | None = None + ) -> None: + """Start a protocol from instrument memory (BGN command). + + Args: + plate_type: Optional plate type override. + protocol_name: Optional protocol name. + """ + cmd = "BGN" + if plate_type is not None: + cmd += f" {plate_type}" + if protocol_name is not None: + cmd += f" {protocol_name}" + await self._send_command(cmd, timeout=COMMAND_TIMEOUTS["BGN"]) # type: ignore[attr-defined] diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/communication.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/communication.py new file mode 100644 index 00000000000..c08de8f02ef --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/communication.py @@ -0,0 +1,223 @@ +"""Low-level serial communication mixin for the Multidrop Combi. + +Ported from the SiLA implementation's serial_transport.py to use +pylabrobot's async Serial wrapper. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Optional + +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.errors import ( + MultidropCombiCommunicationError, + MultidropCombiInstrumentError, +) +from pylabrobot.io.serial import Serial + +logger = logging.getLogger(__name__) + +STATUS_OK = 0 + +ERROR_DESCRIPTIONS = { + 1: "Internal firmware error", + 2: "Unrecognized command", + 3: "Invalid command arguments", + 4: "Pump position error", + 5: "Plate X position error", + 6: "Plate Y position error", + 7: "Z position error", + 9: "Attempt to reset serial number", + 10: "Nonvolatile parameters lost", + 11: "No more memory for user data", + 12: "Pump or X motor was running", + 13: "X and Z positions conflict", + 14: "Cannot dispense: pump not primed", + 15: "Missing prime vessel", + 16: "Rotor shield not in place", + 17: "Dispense volume for all columns is 0", + 18: "Invalid plate type (bad plate index)", + 19: "Plate has not been defined", + 20: "Invalid rows in plate definition", + 21: "Invalid columns in plate definition", + 22: "Plate height is invalid", + 23: "Plate well volume invalid (too small or too big)", + 24: "Invalid cassette type (bad cassette index)", + 25: "Cassette not defined", + 26: "Invalid volume increment for cassette", + 27: "Invalid maximum volume for cassette", + 28: "Invalid minimum volume for cassette", + 29: "Invalid min/max pump speed for cassette", + 30: "Invalid pump rotor offset in cassette definition", + 32: "Dispensing volume not within cassette limits", + 33: "Invalid selector channel", + 34: "Invalid dispensing speed", + 35: "Dispensing height too low for plate", + 36: "Predispense volume not within cassette limits", + 37: "Invalid dispensing order", + 38: "Invalid X or Y dispensing offset", + 39: "RFID option not present", + 40: "RFID tag not present", + 41: "RFID tag data checksum incorrect", + 43: "Wrong cassette type", + 44: "Protocol/plate in use, cannot modify or delete", + 45: "Protocol/plate/cassette is read-only", +} + + +class MultidropCombiCommunicationMixin: + """Mixin providing low-level serial communication for the Multidrop Combi.""" + + io: Optional[Serial] + _command_lock: Optional[asyncio.Lock] + + async def _send_command(self, cmd: str, timeout: float | None = None) -> list[str]: + """Send a command and return the data lines from the response. + + Args: + cmd: Command string (e.g. "DIS", "SPL 1", "SCV 0 500"). + timeout: Per-command read timeout in seconds. If None, uses default. + + Returns: + List of data lines (between echo and END terminator). + + Raises: + MultidropCombiCommunicationError: If not connected or communication fails. + MultidropCombiInstrumentError: If instrument returns non-zero status code. + """ + if self.io is None or self._command_lock is None: + raise MultidropCombiCommunicationError("Not connected to instrument", operation=cmd) + + assert self.io._ser is not None, "Serial port not open. Did you call setup()?" + + cmd_code = cmd.split()[0] + + async with self._command_lock: + original_timeout = self.io._ser.timeout + if timeout is not None: + self.io._ser.timeout = timeout + try: + logger.debug("TX: %r", cmd) + await self.io.write(f"{cmd}\r".encode("ascii")) + + lines: list[str] = [] + while True: + raw = await self.io.readline() + if not raw: + raise MultidropCombiCommunicationError( + f"Timeout reading response for {cmd_code}", operation=cmd + ) + line = raw.decode("ascii", errors="replace").strip() + logger.debug("RX: %r", line) + if not line: + continue + lines.append(line) + + if line.startswith(cmd_code) and " END " in line: + break + + # Parse status from END terminator + end_line = lines[-1] + parts = end_line.split() + status_code = int(parts[-1]) if parts[-1].isdigit() else -1 + + if status_code != STATUS_OK: + desc = ERROR_DESCRIPTIONS.get(status_code, "Unknown error") + logger.error("Command %s failed (status %d). RX lines: %s", cmd_code, status_code, lines) + raise MultidropCombiInstrumentError(status_code, desc) + + # Return data lines: skip echo (first) and END line (last) + # The instrument may echo just the command code or the full command + data_lines = [] + for line in lines[:-1]: + line_upper = line.strip().upper() + if line_upper == cmd.strip().upper() or line_upper == cmd_code.upper(): + continue + data_lines.append(line) + + return data_lines + + except (MultidropCombiCommunicationError, MultidropCombiInstrumentError): + raise + except Exception as e: + raise MultidropCombiCommunicationError( + f"Communication error during {cmd_code}: {e}", + operation=cmd, + original_error=e, + ) from e + finally: + if timeout is not None: + self.io._ser.timeout = original_timeout + + async def _drain_stale_data(self) -> None: + """Drain any stale data from the serial buffer.""" + if self.io is None: + return + + assert self.io._ser is not None + + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() + + original_timeout = self.io._ser.timeout + self.io._ser.timeout = 0.3 + drained = 0 + try: + while True: + stale = await self.io.readline() + if not stale: + break + drained += 1 + logger.debug("Drained stale data: %r", stale) + finally: + self.io._ser.timeout = original_timeout + if drained: + logger.info("Drained %d stale lines from serial buffer", drained) + + async def _enter_remote_mode(self) -> dict: + """Send VER to enter remote control mode and get instrument info. + + Returns: + Dict with keys: instrument_name, firmware_version, serial_number. + """ + try: + lines = await self._send_command("VER", timeout=5.0) + except Exception as first_err: + logger.warning("VER failed (%s), sending EAK and retrying...", first_err) + try: + await self._send_command("EAK", timeout=5.0) + except Exception: + pass + try: + lines = await self._send_command("VER", timeout=5.0) + except Exception as e: + raise MultidropCombiCommunicationError( + f"VER command failed: {e}", operation="VER", original_error=e + ) from e + + info = { + "instrument_name": "Unknown", + "firmware_version": "Unknown", + "serial_number": "Unknown", + } + if lines: + raw = lines[0] + if raw.upper().startswith("VER "): + raw = raw[4:] + parts = raw.split() + if len(parts) > 0: + info["instrument_name"] = parts[0] + if len(parts) > 1: + info["firmware_version"] = parts[1] + if len(parts) > 2: + info["serial_number"] = parts[2] + + return info + + async def _exit_remote_mode(self) -> None: + """Send QIT to exit remote control mode.""" + try: + await self._send_command("QIT", timeout=5.0) + except Exception: + pass diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/demo_multidrop.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/demo_multidrop.py new file mode 100644 index 00000000000..3b7b59d62c6 --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/demo_multidrop.py @@ -0,0 +1,164 @@ +"""Demo script for the Multidrop Combi bulk dispenser. + +Usage: + python demo_multidrop.py COM3 # specify port explicitly (recommended) + python demo_multidrop.py # auto-detect by USB VID/PID (native USB only) +""" + +import asyncio +import sys + +from pylabrobot.bulk_dispensers import BulkDispenser +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi import ( + CassetteType, + DispensingOrder, + MultidropCombiBackend, + MultidropCombiInstrumentError, + plate_to_pla_params, + plate_to_type_index, +) +from pylabrobot.resources.eppendorf.plates import Eppendorf_96_wellplate_250ul_Vb + + +def list_serial_ports(): + """List available serial ports to help the user find the right one.""" + try: + import serial.tools.list_ports + + ports = list(serial.tools.list_ports.comports()) + if not ports: + print(" No serial ports found.") + else: + print(" Available ports:") + for p in ports: + print(f" {p.device} - {p.description} (hwid: {p.hwid})") + except ImportError: + print(" (pyserial not installed, cannot list ports)") + + +async def run_step(name: str, coro): + """Run an async operation with error handling. Returns True on success.""" + try: + await coro + print(f" {name}: OK") + return True + except MultidropCombiInstrumentError as e: + print(f" {name}: INSTRUMENT ERROR (status {e.status_code}): {e.description}") + return False + except Exception as e: + print(f" {name}: ERROR: {type(e).__name__}: {e}") + return False + + +async def main(): + port = sys.argv[1] if len(sys.argv) > 1 else None + + if port is None: + print("No COM port specified. Attempting VID/PID auto-discovery...") + print("(This only works with native USB, not RS232-to-USB adapters)\n") + + # --- Create and connect --- + backend = MultidropCombiBackend(port=port, timeout=30.0) + dispenser = BulkDispenser(backend=backend) + + try: + await dispenser.setup() + except Exception as e: + print(f"Connection failed: {e}\n") + list_serial_ports() + print(f"\nUsage: python {sys.argv[0]} ") + return + + try: + # Connection info + info = backend.get_version() + print( + f"Connected: {info['instrument_name']} " + f"FW {info['firmware_version']} SN {info['serial_number']}" + ) + + # --- Query instrument parameters --- + print("\n--- Instrument Parameters ---") + try: + params = await backend.report_parameters() + for line in params[:10]: + print(f" {line}") + if len(params) > 10: + print(f" ... ({len(params)} lines total)") + except Exception as e: + print(f" REP query failed: {type(e).__name__}: {e}") + + # --- Configure using Eppendorf twin.tec 96-well plate --- + print("\n--- Plate Configuration ---") + plate = Eppendorf_96_wellplate_250ul_Vb("demo_plate") + print(f" Plate: {plate.model}") + print(f" Wells: {plate.num_items} ({plate.num_items_y}x{plate.num_items_x})") + print(f" Height: {plate.get_size_z()} mm") + + # Map to factory type, fall back to PLA remote definition + try: + type_idx = plate_to_type_index(plate) + print(f" Matched factory plate type: {type_idx}") + await run_step("Set plate type (SPL)", dispenser.set_plate_type(plate_type=type_idx)) + except ValueError: + pla_params = plate_to_pla_params(plate) + print(f" No factory match, using remote plate definition: {pla_params}") + await run_step("Define plate (PLA)", backend.define_plate(**pla_params)) + + await run_step( + "Set cassette type (SCT)", dispenser.set_cassette_type(cassette_type=CassetteType.STANDARD) + ) + await run_step( + "Set column volume 10 uL (SCV)", dispenser.set_column_volume(column=0, volume=10.0) + ) + + # Dispensing height must be above the plate. Add 3mm clearance. + dispense_height = round(plate.get_size_z() * 100) + 300 + dispense_height = max(500, min(5500, dispense_height)) # clamp to valid range + print(f" Dispensing height: {dispense_height} (plate {plate.get_size_z()}mm + 3mm clearance)") + await run_step( + f"Set dispensing height {dispense_height} (SDH)", + dispenser.set_dispensing_height(height=dispense_height), + ) + await run_step("Set pump speed 50% (SPS)", dispenser.set_pump_speed(speed=50)) + await run_step( + "Set dispensing order row-wise (SDO)", + dispenser.set_dispensing_order(order=DispensingOrder.ROW_WISE), + ) + + # --- Prime --- + print("\n--- Prime ---") + input(" Press Enter to prime (500 uL)...") + await run_step("Prime 500 uL (PRI)", dispenser.prime(volume=500.0)) + + # --- Dispense --- + print("\n--- Dispense ---") + input(" Press Enter to dispense...") + await run_step("Dispense (DIS)", dispenser.dispense()) + + # --- Shake --- + print("\n--- Shake ---") + input(" Press Enter to shake (3s, 2mm, 10Hz)...") + await run_step("Shake (SHA)", dispenser.shake(time=3.0, distance=2, speed=10)) + + # --- Move plate out --- + print("\n--- Move Plate Out ---") + input(" Press Enter to move plate out...") + await run_step("Move plate out (POU)", dispenser.move_plate_out()) + + # --- Empty --- + print("\n--- Empty ---") + input(" Press Enter to empty hoses (500 uL)...") + await run_step("Empty 500 uL (EMP)", dispenser.empty(volume=500.0)) + + print("\n--- Done! Disconnecting. ---") + + finally: + try: + await dispenser.stop() + except Exception as e: + print(f" Disconnect error: {e}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/enums.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/enums.py new file mode 100644 index 00000000000..af964de8123 --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/enums.py @@ -0,0 +1,25 @@ +import enum + + +class CassetteType(enum.IntEnum): + STANDARD = 0 + SMALL = 1 + USER_DEFINED_1 = 2 + USER_DEFINED_2 = 3 + + +class DispensingOrder(enum.IntEnum): + ROW_WISE = 0 + COLUMN_WISE = 1 + + +class PrimeMode(enum.IntEnum): + STANDARD = 0 + CONTINUOUS = 1 + STOP_CONTINUOUS = 2 + CALIBRATION = 3 + + +class EmptyMode(enum.IntEnum): + STANDARD = 0 + CONTINUOUS = 1 diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/errors.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/errors.py new file mode 100644 index 00000000000..0284adaf58a --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/errors.py @@ -0,0 +1,25 @@ +from __future__ import annotations + + +class MultidropCombiError(Exception): + """Base exception for Multidrop Combi errors.""" + + +class MultidropCombiCommunicationError(MultidropCombiError): + """Serial communication failure (port not found, timeout, connection lost).""" + + def __init__( + self, message: str, operation: str = "", original_error: Exception | None = None + ) -> None: + self.operation = operation + self.original_error = original_error + super().__init__(message) + + +class MultidropCombiInstrumentError(MultidropCombiError): + """Instrument returned a non-zero status code.""" + + def __init__(self, status_code: int, description: str) -> None: + self.status_code = status_code + self.description = description + super().__init__(f"Instrument error (status {status_code}): {description}") diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/helpers.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/helpers.py new file mode 100644 index 00000000000..f310107bbd0 --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/helpers.py @@ -0,0 +1,139 @@ +"""Plate type helpers for the Multidrop Combi. + +Maps PyLabRobot Plate resources to Multidrop Combi plate type indices and +PLA (remote plate definition) command parameters. +""" + +from __future__ import annotations + +from pylabrobot.resources import Plate + +# Multidrop Combi factory plate type definitions (from manual Table 3-3). +# Type index → (well_count, max_plate_height_mm) +# Heights are upper bounds for selecting the best-fit factory type. +_FACTORY_96_WELL_TYPES = [ + # (type_index, max_height_mm) + (0, 18.0), # Type 0: 96-well, 15mm + (1, 30.0), # Type 1: 96-well, 22mm + (2, 55.0), # Type 2: 96-well, 44mm +] + +_FACTORY_384_WELL_TYPES = [ + (3, 8.5), # Type 3: 384-well, 7.5mm + (4, 12.0), # Type 4: 384-well, 10mm + (5, 18.0), # Type 5: 384-well, 15mm + (6, 30.0), # Type 6: 384-well, 22mm + (7, 55.0), # Type 7: 384-well, 44mm +] + +_FACTORY_1536_WELL_TYPES = [ + (8, 7.0), # Type 8: 1536-well, 5mm + (9, 55.0), # Type 9: 1536-well, 10.5mm +] + +# Hardware limits +MAX_COLUMNS = 48 +MAX_ROWS = 32 +MIN_HEIGHT_HUNDREDTHS_MM = 500 # 5mm +MAX_HEIGHT_HUNDREDTHS_MM = 5500 # 55mm +MAX_VOLUME_TENTHS_UL = 25000 # 2500 uL + + +def plate_to_type_index(plate: Plate) -> int: + """Map a PLR Plate to the best-fit Multidrop Combi factory plate type index. + + Selects the factory type based on well count and plate height (size_z). + The smallest factory type whose height threshold accommodates the plate is chosen. + + Args: + plate: A PyLabRobot Plate resource. + + Returns: + Factory plate type index (0-9). + + Raises: + ValueError: If the plate well count is not 96, 384, or 1536, or if the + plate height exceeds all factory type thresholds. + """ + wells = plate.num_items + height_mm = plate.get_size_z() + + if wells == 96: + type_list = _FACTORY_96_WELL_TYPES + elif wells == 384: + type_list = _FACTORY_384_WELL_TYPES + elif wells == 1536: + type_list = _FACTORY_1536_WELL_TYPES + else: + raise ValueError( + f"Unsupported well count: {wells}. " + "Multidrop factory types support 96, 384, or 1536 wells. " + "Use plate_to_pla_params() for custom plate definitions." + ) + + for type_index, max_height in type_list: + if height_mm <= max_height: + return type_index + + raise ValueError( + f"Plate height {height_mm}mm exceeds all factory type thresholds for {wells}-well plates." + ) + + +def plate_to_pla_params(plate: Plate) -> dict: + """Convert a PLR Plate to Multidrop Combi PLA command parameters. + + Use this for plates that don't match factory types (types 0-9), or when you + want precise control over the plate definition sent to the instrument. + The returned dict can be passed directly to ``backend.define_plate(**params)``. + + Args: + plate: A PyLabRobot Plate resource. + + Returns: + Dict with keys matching ``define_plate()`` parameters: + column_positions, row_positions, rows, columns, height, max_volume. + + Raises: + ValueError: If any parameter exceeds Multidrop hardware limits. + """ + columns = plate.num_items_x + rows = plate.num_items_y + height_hundredths = round(plate.get_size_z() * 100) + + # Get max_volume from first well + first_well = plate.get_well("A1") + well_max_volume_tenths = round(first_well.max_volume * 10) + + # Validate against hardware limits + if columns > MAX_COLUMNS: + raise ValueError(f"Plate has {columns} columns, but Multidrop supports at most {MAX_COLUMNS}.") + if rows > MAX_ROWS: + raise ValueError(f"Plate has {rows} rows, but Multidrop supports at most {MAX_ROWS}.") + if height_hundredths < MIN_HEIGHT_HUNDREDTHS_MM: + raise ValueError( + f"Plate height {plate.get_size_z()}mm is below minimum {MIN_HEIGHT_HUNDREDTHS_MM / 100}mm." + ) + if height_hundredths > MAX_HEIGHT_HUNDREDTHS_MM: + raise ValueError( + f"Plate height {plate.get_size_z()}mm exceeds maximum {MAX_HEIGHT_HUNDREDTHS_MM / 100}mm." + ) + if well_max_volume_tenths > MAX_VOLUME_TENTHS_UL: + raise ValueError( + f"Well max volume {first_well.max_volume} uL exceeds Multidrop limit of " + f"{MAX_VOLUME_TENTHS_UL / 10} uL." + ) + + return { + "column_positions": columns, + "row_positions": rows, + "rows": rows, + "columns": columns, + "height": height_hundredths, + "max_volume": well_max_volume_tenths, + } + + +def plate_well_count(plate: Plate) -> int: + """Return the total well count for a plate.""" + return plate.num_items diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/queries.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/queries.py new file mode 100644 index 00000000000..c40ce5d8e6b --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/queries.py @@ -0,0 +1,50 @@ +"""Query operations mixin for the Multidrop Combi.""" + +from __future__ import annotations + + +class MultidropCombiQueriesMixin: + """Mixin providing query operations for the Multidrop Combi.""" + + _instrument_name: str + _firmware_version: str + _serial_number: str + + def get_version(self) -> dict: + """Return cached instrument identification info. + + Returns: + Dict with keys: instrument_name, firmware_version, serial_number. + """ + return { + "instrument_name": self._instrument_name, + "firmware_version": self._firmware_version, + "serial_number": self._serial_number, + } + + async def report_parameters(self) -> list[str]: + """Report instrument parameters (REP command). + + Returns: + List of parameter lines from the instrument. + """ + result: list[str] = await self._send_command("REP", timeout=10.0) # type: ignore[attr-defined] + return result + + async def read_error_log(self) -> list[str]: + """Read the instrument error log (LOG command). + + Returns: + List of error log lines. + """ + result: list[str] = await self._send_command("LOG", timeout=10.0) # type: ignore[attr-defined] + return result + + async def read_cassette_info(self) -> list[str]: + """Read RFID cassette info (RIR command). + + Returns: + List of cassette info lines. + """ + result: list[str] = await self._send_command("RIR", timeout=5.0) # type: ignore[attr-defined] + return result diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/__init__.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/backend_tests.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/backend_tests.py new file mode 100644 index 00000000000..b9063ae1293 --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/backend_tests.py @@ -0,0 +1,63 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.backend import ( + MultidropCombiBackend, +) + + +class BackendSerializationTests(unittest.TestCase): + def test_serialize(self): + backend = MultidropCombiBackend(port="COM3", timeout=15.0) + data = backend.serialize() + self.assertEqual(data["type"], "MultidropCombiBackend") + self.assertEqual(data["port"], "COM3") + self.assertEqual(data["timeout"], 15.0) + + def test_serialize_defaults(self): + backend = MultidropCombiBackend() + data = backend.serialize() + self.assertIsNone(data["port"]) + self.assertEqual(data["timeout"], 30.0) + + +class BackendLifecycleTests(unittest.IsolatedAsyncioTestCase): + @patch("pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.backend.Serial") + async def test_setup_and_stop(self, MockSerial): + mock_serial = MagicMock() + mock_serial.setup = AsyncMock() + mock_serial.stop = AsyncMock() + mock_serial.write = AsyncMock() + mock_serial.readline = AsyncMock() + mock_serial.reset_input_buffer = AsyncMock() + mock_serial.reset_output_buffer = AsyncMock() + mock_serial._ser = MagicMock() + mock_serial._ser.timeout = 30.0 + MockSerial.return_value = mock_serial + + # Setup readline responses: drain (empty), VER, EAK + mock_serial.readline.side_effect = [ + b"", # drain - empty + b"VER\r\n", # VER echo + b"MultidropCombi 2.00.29 836-4191\r\n", # VER data + b"VER END 0\r\n", # VER end + b"EAK\r\n", # EAK echo + b"EAK END 0\r\n", # EAK end + ] + + backend = MultidropCombiBackend(port="COM3") + await backend.setup() + + self.assertEqual(backend._instrument_name, "MultidropCombi") + self.assertEqual(backend._firmware_version, "2.00.29") + self.assertEqual(backend._serial_number, "836-4191") + self.assertIsNotNone(backend.io) + + # Reset readline for QIT during stop + mock_serial.readline.side_effect = [ + b"QIT\r\n", + b"QIT END 0\r\n", + ] + await backend.stop() + self.assertIsNone(backend.io) + mock_serial.stop.assert_awaited_once() diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/commands_tests.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/commands_tests.py new file mode 100644 index 00000000000..f029ba5e2a1 --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/commands_tests.py @@ -0,0 +1,168 @@ +import unittest +from unittest.mock import AsyncMock + +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.commands import ( + MultidropCombiCommandsMixin, + _ul_to_tenths, +) +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.enums import PrimeMode + + +class MockCommandsBackend(MultidropCombiCommandsMixin): + """Testable class with _send_command mocked.""" + + def __init__(self): + self._send_command = AsyncMock(return_value=[]) + + +class VolumeConversionTests(unittest.TestCase): + def test_ul_to_tenths(self): + self.assertEqual(_ul_to_tenths(1.0), 10) + self.assertEqual(_ul_to_tenths(50.0), 500) + self.assertEqual(_ul_to_tenths(0.1), 1) + self.assertEqual(_ul_to_tenths(10000.0), 100000) + + def test_ul_to_tenths_rounding(self): + self.assertEqual(_ul_to_tenths(1.06), 11) + self.assertEqual(_ul_to_tenths(1.04), 10) + + +class CommandFormattingTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = MockCommandsBackend() + + async def test_dispense(self): + await self.backend.dispense() + self.backend._send_command.assert_awaited_once() + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "DIS") + + async def test_prime_standard(self): + await self.backend.prime(volume=50.0) + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "PRI 500") + + async def test_prime_continuous(self): + await self.backend.prime(volume=50.0, mode=PrimeMode.CONTINUOUS) + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "PRI 500 1") + + async def test_empty(self): + await self.backend.empty(volume=100.0) + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "EMP 1000") + + async def test_shake(self): + await self.backend.shake(time=5.0, distance=3, speed=10) + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "SHA 500 3 10") + + async def test_move_plate_out(self): + await self.backend.move_plate_out() + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "POU") + + async def test_set_plate_type(self): + await self.backend.set_plate_type(plate_type=3) + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "SPL 3") + + async def test_set_cassette_type(self): + await self.backend.set_cassette_type(cassette_type=1) + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "SCT 1") + + async def test_set_column_volume(self): + await self.backend.set_column_volume(column=0, volume=25.0) + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "SCV 0 250") + + async def test_set_dispensing_height(self): + await self.backend.set_dispensing_height(height=2500) + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "SDH 2500") + + async def test_set_pump_speed(self): + await self.backend.set_pump_speed(speed=50) + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "SPS 50") + + async def test_set_dispensing_order(self): + await self.backend.set_dispensing_order(order=1) + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "SDO 1") + + async def test_set_dispense_offset(self): + await self.backend.set_dispense_offset(x_offset=100, y_offset=-50) + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "SOF 100 -50") + + async def test_set_predispense_volume(self): + await self.backend.set_predispense_volume(volume=10.0) + args = self.backend._send_command.call_args + self.assertEqual(args[0][0], "SPV 100") + + +class ParameterValidationTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = MockCommandsBackend() + + async def test_prime_volume_too_low(self): + with self.assertRaises(ValueError): + await self.backend.prime(volume=0.0) + + async def test_prime_volume_too_high(self): + with self.assertRaises(ValueError): + await self.backend.prime(volume=20000.0) + + async def test_empty_volume_too_low(self): + with self.assertRaises(ValueError): + await self.backend.empty(volume=0.0) + + async def test_shake_distance_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.shake(time=5.0, distance=0, speed=10) + with self.assertRaises(ValueError): + await self.backend.shake(time=5.0, distance=6, speed=10) + + async def test_shake_speed_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.shake(time=5.0, distance=3, speed=0) + with self.assertRaises(ValueError): + await self.backend.shake(time=5.0, distance=3, speed=21) + + async def test_plate_type_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.set_plate_type(plate_type=-1) + with self.assertRaises(ValueError): + await self.backend.set_plate_type(plate_type=30) + + async def test_cassette_type_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.set_cassette_type(cassette_type=4) + + async def test_column_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.set_column_volume(column=49, volume=10.0) + + async def test_dispensing_height_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.set_dispensing_height(height=499) + with self.assertRaises(ValueError): + await self.backend.set_dispensing_height(height=5501) + + async def test_pump_speed_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.set_pump_speed(speed=0) + with self.assertRaises(ValueError): + await self.backend.set_pump_speed(speed=101) + + async def test_dispensing_order_invalid(self): + with self.assertRaises(ValueError): + await self.backend.set_dispensing_order(order=2) + + async def test_dispense_offset_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.set_dispense_offset(x_offset=301, y_offset=0) + with self.assertRaises(ValueError): + await self.backend.set_dispense_offset(x_offset=0, y_offset=-301) diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/communication_tests.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/communication_tests.py new file mode 100644 index 00000000000..19e83cdae2f --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/communication_tests.py @@ -0,0 +1,162 @@ +import asyncio +import unittest +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.communication import ( + MultidropCombiCommunicationMixin, +) +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.errors import ( + MultidropCombiCommunicationError, + MultidropCombiInstrumentError, +) + + +class MockCommunicationBackend(MultidropCombiCommunicationMixin): + """Testable class that uses the communication mixin with a mock Serial.""" + + def __init__(self) -> None: + self.io: Any = MagicMock() + self.io._ser = MagicMock() + self.io._ser.timeout = 30.0 + self._command_lock = asyncio.Lock() + + # Make io.write and io.readline async + self.io.write = AsyncMock() + self.io.readline = AsyncMock() + self.io.reset_input_buffer = AsyncMock() + self.io.reset_output_buffer = AsyncMock() + + +class SendCommandTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.backend = MockCommunicationBackend() + + async def test_simple_command(self) -> None: + """Test a simple command with echo + END response.""" + self.backend.io.readline.side_effect = [ + b"SPL\r\n", # echo + b"SPL END 0\r\n", # end with status 0 + ] + result = await self.backend._send_command("SPL 1") + self.assertEqual(result, []) + self.backend.io.write.assert_awaited_once_with(b"SPL 1\r") + + async def test_command_with_data_lines(self) -> None: + """Test a command that returns data lines between echo and END.""" + self.backend.io.readline.side_effect = [ + b"VER\r\n", + b"MultidropCombi 2.00.29 836-4191\r\n", + b"VER END 0\r\n", + ] + result = await self.backend._send_command("VER") + self.assertEqual(result, ["MultidropCombi 2.00.29 836-4191"]) + + async def test_command_with_error_status(self) -> None: + """Test that non-zero status raises MultidropCombiInstrumentError.""" + self.backend.io.readline.side_effect = [ + b"SPL\r\n", + b"SPL END 18\r\n", # status 18 = Invalid plate type + ] + with self.assertRaises(MultidropCombiInstrumentError) as ctx: + await self.backend._send_command("SPL 99") + self.assertEqual(ctx.exception.status_code, 18) + self.assertIn("Invalid plate type", ctx.exception.description) + + async def test_timeout_raises_communication_error(self) -> None: + """Test that timeout (empty readline) raises MultidropCombiCommunicationError.""" + self.backend.io.readline.side_effect = [b""] + with self.assertRaises(MultidropCombiCommunicationError): + await self.backend._send_command("SPL 1") + + async def test_not_connected(self) -> None: + """Test that sending a command when io is None raises error.""" + self.backend.io = None + with self.assertRaises(MultidropCombiCommunicationError): + await self.backend._send_command("VER") + + async def test_custom_timeout(self) -> None: + """Test that custom timeout is set and restored.""" + self.backend.io.readline.side_effect = [ + b"POU\r\n", + b"POU END 0\r\n", + ] + original = self.backend.io._ser.timeout + await self.backend._send_command("POU", timeout=10.0) + # Timeout should be restored after command + self.assertEqual(self.backend.io._ser.timeout, original) + + async def test_echo_skipping_case_insensitive(self) -> None: + """Test that echo is skipped regardless of case.""" + self.backend.io.readline.side_effect = [ + b"ver\r\n", # lowercase echo + b"MultidropCombi 2.00.29 836-4191\r\n", + b"VER END 0\r\n", + ] + result = await self.backend._send_command("VER") + self.assertEqual(result, ["MultidropCombi 2.00.29 836-4191"]) + + +class EnterRemoteModeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.backend = MockCommunicationBackend() + + async def test_enter_remote_mode_success(self) -> None: + """Test successful VER command parses instrument info.""" + self.backend.io.readline.side_effect = [ + b"VER\r\n", + b"MultidropCombi 2.00.29 836-4191\r\n", + b"VER END 0\r\n", + ] + info = await self.backend._enter_remote_mode() + self.assertEqual(info["instrument_name"], "MultidropCombi") + self.assertEqual(info["firmware_version"], "2.00.29") + self.assertEqual(info["serial_number"], "836-4191") + + async def test_enter_remote_mode_retry_after_eak(self) -> None: + """Test VER retry after EAK when first VER fails.""" + call_count = 0 + + async def readline_side_effect() -> bytes: + nonlocal call_count + call_count += 1 + responses = [ + # First VER attempt - fails with error + b"VER\r\n", + b"VER END 1\r\n", + # EAK attempt + b"EAK\r\n", + b"EAK END 0\r\n", + # Second VER attempt - succeeds + b"VER\r\n", + b"MultidropCombi 2.00.29 836-4191\r\n", + b"VER END 0\r\n", + ] + if call_count <= len(responses): + return responses[call_count - 1] + return b"" + + self.backend.io.readline.side_effect = readline_side_effect + info = await self.backend._enter_remote_mode() + self.assertEqual(info["instrument_name"], "MultidropCombi") + + +class DrainStaleDataTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.backend = MockCommunicationBackend() + + async def test_drain_with_stale_data(self) -> None: + """Test draining stale data from buffer.""" + self.backend.io.readline.side_effect = [ + b"stale line 1\r\n", + b"stale line 2\r\n", + b"", # No more data + ] + await self.backend._drain_stale_data() + self.backend.io.reset_input_buffer.assert_awaited_once() + self.backend.io.reset_output_buffer.assert_awaited_once() + + async def test_drain_empty_buffer(self) -> None: + """Test draining when buffer is already empty.""" + self.backend.io.readline.side_effect = [b""] + await self.backend._drain_stale_data() diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/helpers_tests.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/helpers_tests.py new file mode 100644 index 00000000000..0aeb2cd37dd --- /dev/null +++ b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/helpers_tests.py @@ -0,0 +1,209 @@ +import unittest + +from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.helpers import ( + plate_to_pla_params, + plate_to_type_index, + plate_well_count, +) +from pylabrobot.resources import Plate, Well, create_ordered_items_2d +from pylabrobot.resources.well import CrossSectionType, WellBottomType + + +def _make_plate( + num_items_x: int = 12, + num_items_y: int = 8, + size_z: float = 14.2, + well_max_volume: float = 360.0, + well_size_z: float = 10.67, +) -> Plate: + """Create a test plate with the given parameters.""" + return Plate( + name="test_plate", + size_x=127.76, + size_y=85.48, + size_z=size_z, + model="test", + ordered_items=create_ordered_items_2d( + Well, + num_items_x=num_items_x, + num_items_y=num_items_y, + dx=10.0, + dy=7.0, + dz=1.0, + item_dx=9.0, + item_dy=9.0, + size_x=6.0, + size_y=6.0, + size_z=well_size_z, + bottom_type=WellBottomType.FLAT, + cross_section_type=CrossSectionType.CIRCLE, + max_volume=well_max_volume, + ), + ) + + +class PlateToTypeIndexTests(unittest.TestCase): + """Test factory plate type mapping.""" + + def test_96_well_short(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=14.0) + self.assertEqual(plate_to_type_index(plate), 0) # 15mm type + + def test_96_well_medium(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=20.0) + self.assertEqual(plate_to_type_index(plate), 1) # 22mm type + + def test_96_well_tall(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=40.0) + self.assertEqual(plate_to_type_index(plate), 2) # 44mm type + + def test_384_well_very_short(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=7.0) + self.assertEqual(plate_to_type_index(plate), 3) # 7.5mm type + + def test_384_well_short(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=10.0) + self.assertEqual(plate_to_type_index(plate), 4) # 10mm type + + def test_384_well_medium(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=14.0) + self.assertEqual(plate_to_type_index(plate), 5) # 15mm type + + def test_384_well_tall(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=25.0) + self.assertEqual(plate_to_type_index(plate), 6) # 22mm type + + def test_384_well_very_tall(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=44.0) + self.assertEqual(plate_to_type_index(plate), 7) # 44mm type + + def test_1536_well_short(self): + plate = _make_plate(num_items_x=48, num_items_y=32, size_z=5.0) + self.assertEqual(plate_to_type_index(plate), 8) # 5mm type + + def test_1536_well_tall(self): + plate = _make_plate(num_items_x=48, num_items_y=32, size_z=10.0) + self.assertEqual(plate_to_type_index(plate), 9) # 10.5mm type + + def test_unsupported_well_count(self): + plate = _make_plate(num_items_x=6, num_items_y=4) # 24-well + with self.assertRaises(ValueError) as ctx: + plate_to_type_index(plate) + self.assertIn("24", str(ctx.exception)) + + def test_96_well_too_tall(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=60.0) + with self.assertRaises(ValueError): + plate_to_type_index(plate) + + +class PlateToTypeIndexRealPlatesTests(unittest.TestCase): + """Test with real PLR plate definitions.""" + + def test_corning_96_well(self): + from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb + + plate = Cor_96_wellplate_360ul_Fb("test") + self.assertEqual(plate_to_type_index(plate), 0) # 14.2mm → type 0 + + def test_biorad_384_well(self): + from pylabrobot.resources.biorad.plates import BioRad_384_wellplate_50uL_Vb + + plate = BioRad_384_wellplate_50uL_Vb("test") + self.assertEqual(plate_to_type_index(plate), 4) # 10.4mm → type 4 + + +class PlateToPlaParamsTests(unittest.TestCase): + """Test PLA command parameter generation.""" + + def test_96_well_params(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=14.2, well_max_volume=360.0) + params = plate_to_pla_params(plate) + self.assertEqual(params["columns"], 12) + self.assertEqual(params["rows"], 8) + self.assertEqual(params["column_positions"], 12) + self.assertEqual(params["row_positions"], 8) + self.assertEqual(params["height"], 1420) # 14.2mm * 100 + self.assertEqual(params["max_volume"], 3600) # 360uL * 10 + + def test_384_well_params(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=10.4, well_max_volume=50.0) + params = plate_to_pla_params(plate) + self.assertEqual(params["columns"], 24) + self.assertEqual(params["rows"], 16) + self.assertEqual(params["height"], 1040) + self.assertEqual(params["max_volume"], 500) + + def test_real_corning_96_well(self): + from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb + + plate = Cor_96_wellplate_360ul_Fb("test") + params = plate_to_pla_params(plate) + self.assertEqual(params["columns"], 12) + self.assertEqual(params["rows"], 8) + self.assertEqual(params["height"], 1420) + self.assertEqual(params["max_volume"], 3600) + + +class PlaParamsValidationTests(unittest.TestCase): + """Test parameter validation in plate_to_pla_params.""" + + def test_too_many_columns(self): + plate = _make_plate(num_items_x=49, num_items_y=8, size_z=14.0) + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("49 columns", str(ctx.exception)) + self.assertIn("48", str(ctx.exception)) + + def test_too_many_rows(self): + plate = _make_plate(num_items_x=12, num_items_y=33, size_z=14.0) + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("33 rows", str(ctx.exception)) + self.assertIn("32", str(ctx.exception)) + + def test_height_too_low(self): + plate = _make_plate(size_z=4.0) # 4mm < 5mm minimum + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("4.0mm", str(ctx.exception)) + self.assertIn("minimum", str(ctx.exception)) + + def test_height_too_high(self): + plate = _make_plate(size_z=60.0) # 60mm > 55mm maximum + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("60.0mm", str(ctx.exception)) + self.assertIn("maximum", str(ctx.exception)) + + def test_well_volume_too_high(self): + plate = _make_plate(well_max_volume=3000.0) # 3000uL > 2500uL max + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("3000", str(ctx.exception)) + self.assertIn("2500", str(ctx.exception)) + + def test_height_at_minimum_boundary(self): + plate = _make_plate(size_z=5.0) # exactly 5mm = 500 hundredths + params = plate_to_pla_params(plate) + self.assertEqual(params["height"], 500) + + def test_height_at_maximum_boundary(self): + plate = _make_plate(size_z=55.0) # exactly 55mm = 5500 hundredths + params = plate_to_pla_params(plate) + self.assertEqual(params["height"], 5500) + + def test_volume_at_maximum_boundary(self): + plate = _make_plate(well_max_volume=2500.0) # exactly 2500uL + params = plate_to_pla_params(plate) + self.assertEqual(params["max_volume"], 25000) + + +class PlateWellCountTests(unittest.TestCase): + def test_96_well(self): + plate = _make_plate(num_items_x=12, num_items_y=8) + self.assertEqual(plate_well_count(plate), 96) + + def test_384_well(self): + plate = _make_plate(num_items_x=24, num_items_y=16) + self.assertEqual(plate_well_count(plate), 384) diff --git a/pylabrobot/byonoy/absorbance_96.py b/pylabrobot/byonoy/absorbance_96.py index 6b8031df3e3..e1b4e4f5389 100644 --- a/pylabrobot/byonoy/absorbance_96.py +++ b/pylabrobot/byonoy/absorbance_96.py @@ -4,7 +4,7 @@ from pylabrobot.byonoy.backend import ByonoyBase, ByonoyDevice from pylabrobot.capabilities.plate_reading.absorbance import ( AbsorbanceBackend, - AbsorbanceCapability, + Absorbance, AbsorbanceResult, ) from pylabrobot.device import Device @@ -282,8 +282,8 @@ def __init__(self, name: str = "byonoy_absorbance_96"): backend = ByonoyAbsorbance96Backend() ByonoyAbsorbanceBaseUnit.__init__(self, name=name + "_base") Device.__init__(self, driver=backend) - self._driver: ByonoyAbsorbance96Backend = backend - self.absorbance = AbsorbanceCapability(backend=backend) + self.driver: ByonoyAbsorbance96Backend = backend + self.absorbance = Absorbance(backend=backend) self._capabilities = [self.absorbance] def serialize(self) -> dict: diff --git a/pylabrobot/byonoy/luminescence_96.py b/pylabrobot/byonoy/luminescence_96.py index e148354bf8d..19a7ff95728 100644 --- a/pylabrobot/byonoy/luminescence_96.py +++ b/pylabrobot/byonoy/luminescence_96.py @@ -6,7 +6,7 @@ from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.plate_reading.luminescence import ( LuminescenceBackend, - LuminescenceCapability, + Luminescence, LuminescenceResult, ) from pylabrobot.device import Device @@ -241,8 +241,8 @@ def __init__( preferred_pickup_location=preferred_pickup_location, ) Device.__init__(self, driver=backend) - self._driver: ByonoyLuminescence96Backend = backend - self.luminescence = LuminescenceCapability(backend=backend) + self.driver: ByonoyLuminescence96Backend = backend + self.luminescence = Luminescence(backend=backend) self._capabilities = [self.luminescence] def serialize(self) -> dict: diff --git a/pylabrobot/capabilities/automated_retrieval/__init__.py b/pylabrobot/capabilities/automated_retrieval/__init__.py index 3cec37375ef..b4bd3544909 100644 --- a/pylabrobot/capabilities/automated_retrieval/__init__.py +++ b/pylabrobot/capabilities/automated_retrieval/__init__.py @@ -1,2 +1,2 @@ -from .automated_retrieval import AutomatedRetrievalCapability +from .automated_retrieval import AutomatedRetrieval from .backend import AutomatedRetrievalBackend diff --git a/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py index eb609f48b05..51b78ea0f23 100644 --- a/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py +++ b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py @@ -4,7 +4,7 @@ from .backend import AutomatedRetrievalBackend -class AutomatedRetrievalCapability(Capability): +class AutomatedRetrieval(Capability): """Automated plate retrieval/storage capability.""" def __init__(self, backend: AutomatedRetrievalBackend): diff --git a/pylabrobot/capabilities/automated_retrieval/chatterbox.py b/pylabrobot/capabilities/automated_retrieval/chatterbox.py new file mode 100644 index 00000000000..6fe09e85527 --- /dev/null +++ b/pylabrobot/capabilities/automated_retrieval/chatterbox.py @@ -0,0 +1,18 @@ +import logging + +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.carrier import PlateHolder + +from .backend import AutomatedRetrievalBackend + +logger = logging.getLogger(__name__) + + +class AutomatedRetrievalChatterboxBackend(AutomatedRetrievalBackend): + """Chatterbox backend for device-free testing.""" + + async def fetch_plate_to_loading_tray(self, plate: Plate): + logger.info("Fetching plate %s to loading tray.", plate.name) + + async def store_plate(self, plate: Plate, site: PlateHolder): + logger.info("Storing plate %s at site %s.", plate.name, site.name) diff --git a/pylabrobot/capabilities/barcode_scanning/__init__.py b/pylabrobot/capabilities/barcode_scanning/__init__.py index 535b3a6425e..498af9e43da 100644 --- a/pylabrobot/capabilities/barcode_scanning/__init__.py +++ b/pylabrobot/capabilities/barcode_scanning/__init__.py @@ -1,2 +1,2 @@ from .backend import BarcodeScannerBackend, BarcodeScannerError -from .barcode_scanning import BarcodeScanningCapability +from .barcode_scanning import BarcodeScanner diff --git a/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py b/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py index 0be931e9512..b627e70fcb8 100644 --- a/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py +++ b/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py @@ -4,7 +4,7 @@ from .backend import BarcodeScannerBackend -class BarcodeScanningCapability(Capability): +class BarcodeScanner(Capability): """Barcode scanning capability.""" def __init__(self, backend: BarcodeScannerBackend): diff --git a/pylabrobot/capabilities/barcode_scanning/chatterbox.py b/pylabrobot/capabilities/barcode_scanning/chatterbox.py new file mode 100644 index 00000000000..73452224e24 --- /dev/null +++ b/pylabrobot/capabilities/barcode_scanning/chatterbox.py @@ -0,0 +1,18 @@ +import logging + +from pylabrobot.resources.barcode import Barcode + +from .backend import BarcodeScannerBackend + +logger = logging.getLogger(__name__) + + +class BarcodeScannerChatterboxBackend(BarcodeScannerBackend): + """Chatterbox backend for device-free testing.""" + + def __init__(self, barcode: str = "CHATTERBOX-001"): + self.barcode = barcode + + async def scan_barcode(self) -> Barcode: + logger.info("Scanning barcode.") + return Barcode(data=self.barcode, symbology="Code 128 (Subset B and C)", position_on_resource="front") diff --git a/pylabrobot/capabilities/centrifuging/__init__.py b/pylabrobot/capabilities/centrifuging/__init__.py index 153b48091b9..8dd360fd3eb 100644 --- a/pylabrobot/capabilities/centrifuging/__init__.py +++ b/pylabrobot/capabilities/centrifuging/__init__.py @@ -1,5 +1,5 @@ from .backend import CentrifugeBackend -from .centrifuging import CentrifugingCapability +from .centrifuging import Centrifuge from .errors import ( BucketHasPlateError, BucketNoPlateError, diff --git a/pylabrobot/capabilities/centrifuging/centrifuging.py b/pylabrobot/capabilities/centrifuging/centrifuging.py index 23686217e98..751a928f84b 100644 --- a/pylabrobot/capabilities/centrifuging/centrifuging.py +++ b/pylabrobot/capabilities/centrifuging/centrifuging.py @@ -7,7 +7,7 @@ from .backend import CentrifugeBackend -class CentrifugingCapability(Capability): +class Centrifuge(Capability): """Centrifuging capability.""" def __init__( diff --git a/pylabrobot/capabilities/centrifuging/chatterbox.py b/pylabrobot/capabilities/centrifuging/chatterbox.py new file mode 100644 index 00000000000..a710a49e461 --- /dev/null +++ b/pylabrobot/capabilities/centrifuging/chatterbox.py @@ -0,0 +1,44 @@ +import logging +from typing import Optional + +from pylabrobot.serializer import SerializableMixin + +from .backend import CentrifugeBackend + +logger = logging.getLogger(__name__) + + +class CentrifugeChatterboxBackend(CentrifugeBackend): + """Chatterbox backend for device-free testing.""" + + async def open_door(self) -> None: + logger.info("Opening centrifuge door.") + + async def close_door(self) -> None: + logger.info("Closing centrifuge door.") + + async def lock_door(self) -> None: + logger.info("Locking centrifuge door.") + + async def unlock_door(self) -> None: + logger.info("Unlocking centrifuge door.") + + async def go_to_bucket1(self) -> None: + logger.info("Rotating to bucket 1.") + + async def go_to_bucket2(self) -> None: + logger.info("Rotating to bucket 2.") + + async def lock_bucket(self) -> None: + logger.info("Locking bucket.") + + async def unlock_bucket(self) -> None: + logger.info("Unlocking bucket.") + + async def spin( + self, + g: float, + duration: float, + backend_params: Optional[SerializableMixin] = None, + ) -> None: + logger.info("Spinning at %s g for %s seconds.", g, duration) diff --git a/pylabrobot/capabilities/fan_control/__init__.py b/pylabrobot/capabilities/fan_control/__init__.py index 0d9aeefb401..d4360454b29 100644 --- a/pylabrobot/capabilities/fan_control/__init__.py +++ b/pylabrobot/capabilities/fan_control/__init__.py @@ -1,2 +1,2 @@ from .backend import FanBackend -from .fan_control import FanControlCapability +from .fan_control import Fan diff --git a/pylabrobot/capabilities/fan_control/chatterbox.py b/pylabrobot/capabilities/fan_control/chatterbox.py new file mode 100644 index 00000000000..fec6d8e5277 --- /dev/null +++ b/pylabrobot/capabilities/fan_control/chatterbox.py @@ -0,0 +1,15 @@ +import logging + +from .backend import FanBackend + +logger = logging.getLogger(__name__) + + +class FanChatterboxBackend(FanBackend): + """Chatterbox backend for device-free testing.""" + + async def turn_on(self, intensity: int) -> None: + logger.info("Turning fan on at %s%%.", intensity) + + async def turn_off(self) -> None: + logger.info("Turning fan off.") diff --git a/pylabrobot/capabilities/fan_control/fan_control.py b/pylabrobot/capabilities/fan_control/fan_control.py index 76f9eaa6b05..407cd7e8bbe 100644 --- a/pylabrobot/capabilities/fan_control/fan_control.py +++ b/pylabrobot/capabilities/fan_control/fan_control.py @@ -5,7 +5,7 @@ from .backend import FanBackend -class FanControlCapability(Capability): +class Fan(Capability): """Fan control capability.""" def __init__(self, backend: FanBackend): diff --git a/pylabrobot/capabilities/humidity_controlling/__init__.py b/pylabrobot/capabilities/humidity_controlling/__init__.py index ea02818a921..ece6c893114 100644 --- a/pylabrobot/capabilities/humidity_controlling/__init__.py +++ b/pylabrobot/capabilities/humidity_controlling/__init__.py @@ -1,2 +1,2 @@ from .backend import HumidityControllerBackend -from .humidity_controller import HumidityControlCapability +from .humidity_controller import HumidityController diff --git a/pylabrobot/capabilities/humidity_controlling/chatterbox.py b/pylabrobot/capabilities/humidity_controlling/chatterbox.py new file mode 100644 index 00000000000..d484b0675fe --- /dev/null +++ b/pylabrobot/capabilities/humidity_controlling/chatterbox.py @@ -0,0 +1,23 @@ +import logging + +from .backend import HumidityControllerBackend + +logger = logging.getLogger(__name__) + + +class HumidityControllerChatterboxBackend(HumidityControllerBackend): + """Chatterbox backend for device-free testing.""" + + def __init__(self): + self._humidity = 0.5 + + @property + def supports_humidity_control(self) -> bool: + return True + + async def set_humidity(self, humidity: float): + logger.info("Setting humidity to %s.", humidity) + self._humidity = humidity + + async def request_current_humidity(self) -> float: + return self._humidity diff --git a/pylabrobot/capabilities/humidity_controlling/humidity_controller.py b/pylabrobot/capabilities/humidity_controlling/humidity_controller.py index 622bed96932..ab72799c572 100644 --- a/pylabrobot/capabilities/humidity_controlling/humidity_controller.py +++ b/pylabrobot/capabilities/humidity_controlling/humidity_controller.py @@ -3,7 +3,7 @@ from .backend import HumidityControllerBackend -class HumidityControlCapability(Capability): +class HumidityController(Capability): """Humidity control capability.""" def __init__(self, backend: HumidityControllerBackend): diff --git a/pylabrobot/capabilities/liquid_handling/__init__.py b/pylabrobot/capabilities/liquid_handling/__init__.py index fb6209587e7..f193d242e57 100644 --- a/pylabrobot/capabilities/liquid_handling/__init__.py +++ b/pylabrobot/capabilities/liquid_handling/__init__.py @@ -1,5 +1,5 @@ from .errors import ChannelizedError, NoChannelError -from .head96 import Head96Capability +from .head96 import Head96 from .head96_backend import Head96Backend from .pip import PIP from .pip_backend import PIPBackend diff --git a/pylabrobot/capabilities/liquid_handling/head96.py b/pylabrobot/capabilities/liquid_handling/head96.py index f0c38de29e5..5f53f5061c3 100644 --- a/pylabrobot/capabilities/liquid_handling/head96.py +++ b/pylabrobot/capabilities/liquid_handling/head96.py @@ -31,7 +31,7 @@ logger = logging.getLogger("pylabrobot") -class Head96Capability(Capability): +class Head96(Capability): """96-head liquid handling: pick up tips, aspirate, dispense, drop tips. Faithfully ports the 96-head logic from the legacy LiquidHandler, including diff --git a/pylabrobot/capabilities/microscopy/__init__.py b/pylabrobot/capabilities/microscopy/__init__.py index 8084e92f272..64e321f977d 100644 --- a/pylabrobot/capabilities/microscopy/__init__.py +++ b/pylabrobot/capabilities/microscopy/__init__.py @@ -1,6 +1,6 @@ from .backend import MicroscopyBackend from .microscopy import ( - MicroscopyCapability, + Microscopy, evaluate_focus_nvmg_sobel, fraction_overexposed, max_pixel_at_fraction, diff --git a/pylabrobot/capabilities/microscopy/chatterbox.py b/pylabrobot/capabilities/microscopy/chatterbox.py index 7043d671229..394395bf089 100644 --- a/pylabrobot/capabilities/microscopy/chatterbox.py +++ b/pylabrobot/capabilities/microscopy/chatterbox.py @@ -37,6 +37,7 @@ async def capture( backend_params: Optional[SerializableMixin] = None, ) -> ImagingResult: if HAS_NUMPY: + assert np is not None image = np.zeros((512, 512), dtype=np.uint16) else: image = [[0] * 512 for _ in range(512)] # type: ignore diff --git a/pylabrobot/capabilities/microscopy/microscopy.py b/pylabrobot/capabilities/microscopy/microscopy.py index a977b5bcfbe..ed1f3b7bf52 100644 --- a/pylabrobot/capabilities/microscopy/microscopy.py +++ b/pylabrobot/capabilities/microscopy/microscopy.py @@ -58,7 +58,7 @@ async def cached_func(x: float) -> float: return (b + a) / 2 -class MicroscopyCapability(Capability): +class Microscopy(Capability): """Microscopy imaging capability. Provides high-level image capture with support for auto-exposure and auto-focus. diff --git a/pylabrobot/capabilities/microscopy/microscopy_tests.py b/pylabrobot/capabilities/microscopy/microscopy_tests.py index fa2a793da0e..d6faf9e4d0b 100644 --- a/pylabrobot/capabilities/microscopy/microscopy_tests.py +++ b/pylabrobot/capabilities/microscopy/microscopy_tests.py @@ -1,4 +1,4 @@ -"""Tests for MicroscopyCapability.""" +"""Tests for Microscopy.""" import unittest from typing import List, Optional, Tuple @@ -11,7 +11,7 @@ from pylabrobot.capabilities.microscopy.backend import MicroscopyBackend from pylabrobot.capabilities.microscopy.chatterbox import MicroscopyChatterboxBackend -from pylabrobot.capabilities.microscopy.microscopy import MicroscopyCapability +from pylabrobot.capabilities.microscopy.microscopy import Microscopy from pylabrobot.capabilities.microscopy.standard import ( Exposure, FocalPosition, @@ -77,10 +77,10 @@ async def capture( ) -class TestMicroscopyCapability(unittest.IsolatedAsyncioTestCase): +class TestMicroscopy(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingMicroscopyBackend() - self.cap = MicroscopyCapability(backend=self.backend) + self.cap = Microscopy(backend=self.backend) await self.cap._on_setup() self.plate = _test_plate() @@ -140,7 +140,7 @@ async def test_capture_machine_auto(self): async def test_capture_requires_setup(self): backend = RecordingMicroscopyBackend() - cap = MicroscopyCapability(backend=backend) + cap = Microscopy(backend=backend) with self.assertRaises(RuntimeError): await cap.capture( well=(0, 0), @@ -153,7 +153,7 @@ async def test_capture_requires_setup(self): class TestChatterboxBackend(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_capture(self): backend = MicroscopyChatterboxBackend() - cap = MicroscopyCapability(backend=backend) + cap = Microscopy(backend=backend) await cap._on_setup() plate = _test_plate() diff --git a/pylabrobot/capabilities/peeling/__init__.py b/pylabrobot/capabilities/peeling/__init__.py index 20e3598871b..663bab1f45c 100644 --- a/pylabrobot/capabilities/peeling/__init__.py +++ b/pylabrobot/capabilities/peeling/__init__.py @@ -1,2 +1,2 @@ from .backend import PeelerBackend -from .peeling import PeelingCapability +from .peeling import Peeler diff --git a/pylabrobot/capabilities/peeling/chatterbox.py b/pylabrobot/capabilities/peeling/chatterbox.py new file mode 100644 index 00000000000..dcdd3381b7c --- /dev/null +++ b/pylabrobot/capabilities/peeling/chatterbox.py @@ -0,0 +1,18 @@ +import logging +from typing import Optional + +from pylabrobot.serializer import SerializableMixin + +from .backend import PeelerBackend + +logger = logging.getLogger(__name__) + + +class PeelerChatterboxBackend(PeelerBackend): + """Chatterbox backend for device-free testing.""" + + async def peel(self, backend_params: Optional[SerializableMixin] = None): + logger.info("Running peel cycle.") + + async def restart(self, backend_params: Optional[SerializableMixin] = None): + logger.info("Restarting peeler.") diff --git a/pylabrobot/capabilities/peeling/peeling.py b/pylabrobot/capabilities/peeling/peeling.py index 6a9f0e162f7..2c1d1d71f15 100644 --- a/pylabrobot/capabilities/peeling/peeling.py +++ b/pylabrobot/capabilities/peeling/peeling.py @@ -6,7 +6,7 @@ from .backend import PeelerBackend -class PeelingCapability(Capability): +class Peeler(Capability): """Peeling capability.""" def __init__(self, backend: PeelerBackend): diff --git a/pylabrobot/capabilities/plate_reading/absorbance/__init__.py b/pylabrobot/capabilities/plate_reading/absorbance/__init__.py index 715f3f3b4fb..475da20e46f 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/__init__.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/__init__.py @@ -1,3 +1,3 @@ -from .absorbance import AbsorbanceCapability +from .absorbance import Absorbance from .backend import AbsorbanceBackend from .standard import AbsorbanceResult diff --git a/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py b/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py index d8cd7b54076..4eeb5343598 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) -class AbsorbanceCapability(Capability): +class Absorbance(Capability): """Absorbance plate reading capability.""" def __init__(self, backend: AbsorbanceBackend): diff --git a/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py index ee65296aabd..9e4d2c20abb 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/absorbance_tests.py @@ -1,9 +1,9 @@ -"""Tests for AbsorbanceCapability.""" +"""Tests for Absorbance.""" import unittest from typing import List, Optional, Tuple -from pylabrobot.capabilities.plate_reading.absorbance.absorbance import AbsorbanceCapability +from pylabrobot.capabilities.plate_reading.absorbance.absorbance import Absorbance from pylabrobot.capabilities.plate_reading.absorbance.backend import AbsorbanceBackend from pylabrobot.capabilities.plate_reading.absorbance.chatterbox import ( AbsorbanceChatterboxBackend, @@ -63,10 +63,10 @@ async def read_absorbance( return [AbsorbanceResult(data=data, wavelength=wavelength, temperature=None, timestamp=0.0)] -class TestAbsorbanceCapability(unittest.IsolatedAsyncioTestCase): +class TestAbsorbance(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingAbsorbanceBackend() - self.cap = AbsorbanceCapability(backend=self.backend) + self.cap = Absorbance(backend=self.backend) await self.cap._on_setup() self.plate = _test_plate() @@ -89,7 +89,7 @@ async def test_read_all_wells(self): async def test_read_requires_setup(self): backend = RecordingAbsorbanceBackend() - cap = AbsorbanceCapability(backend=backend) + cap = Absorbance(backend=backend) with self.assertRaises(RuntimeError): await cap.read(plate=self.plate, wavelength=450) @@ -97,7 +97,7 @@ async def test_read_requires_setup(self): class TestAbsorbanceChatterbox(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_read(self): backend = AbsorbanceChatterboxBackend() - cap = AbsorbanceCapability(backend=backend) + cap = Absorbance(backend=backend) await cap._on_setup() plate = _test_plate() diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/__init__.py b/pylabrobot/capabilities/plate_reading/fluorescence/__init__.py index 248793e0485..490739cbea4 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/__init__.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/__init__.py @@ -1,3 +1,3 @@ from .backend import FluorescenceBackend -from .fluorescence import FluorescenceCapability +from .fluorescence import Fluorescence from .standard import FluorescenceResult diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py index e7aa0978195..71079f5dd01 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) -class FluorescenceCapability(Capability): +class Fluorescence(Capability): """Fluorescence plate reading capability.""" def __init__(self, backend: FluorescenceBackend): diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py index 0e1d2578701..2067438e461 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence_tests.py @@ -1,4 +1,4 @@ -"""Tests for FluorescenceCapability.""" +"""Tests for Fluorescence.""" import unittest from typing import List, Optional @@ -7,7 +7,7 @@ from pylabrobot.capabilities.plate_reading.fluorescence.chatterbox import ( FluorescenceChatterboxBackend, ) -from pylabrobot.capabilities.plate_reading.fluorescence.fluorescence import FluorescenceCapability +from pylabrobot.capabilities.plate_reading.fluorescence.fluorescence import Fluorescence from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d @@ -72,10 +72,10 @@ async def read_fluorescence( ] -class TestFluorescenceCapability(unittest.IsolatedAsyncioTestCase): +class TestFluorescence(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingFluorescenceBackend() - self.cap = FluorescenceCapability(backend=self.backend) + self.cap = Fluorescence(backend=self.backend) await self.cap._on_setup() self.plate = _test_plate() @@ -111,7 +111,7 @@ async def test_read_all_wells(self): async def test_read_requires_setup(self): backend = RecordingFluorescenceBackend() - cap = FluorescenceCapability(backend=backend) + cap = Fluorescence(backend=backend) with self.assertRaises(RuntimeError): await cap.read( plate=self.plate, @@ -124,7 +124,7 @@ async def test_read_requires_setup(self): class TestFluorescenceChatterbox(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_read(self): backend = FluorescenceChatterboxBackend() - cap = FluorescenceCapability(backend=backend) + cap = Fluorescence(backend=backend) await cap._on_setup() plate = _test_plate() diff --git a/pylabrobot/capabilities/plate_reading/luminescence/__init__.py b/pylabrobot/capabilities/plate_reading/luminescence/__init__.py index 53243a8832b..ec374ef7ec2 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/__init__.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/__init__.py @@ -1,3 +1,3 @@ from .backend import LuminescenceBackend -from .luminescence import LuminescenceCapability +from .luminescence import Luminescence from .standard import LuminescenceResult diff --git a/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py b/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py index 8d1af79789b..e4b9785b106 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__) -class LuminescenceCapability(Capability): +class Luminescence(Capability): """Luminescence plate reading capability.""" def __init__(self, backend: LuminescenceBackend): diff --git a/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py index 91655625afd..342858aef3a 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/luminescence_tests.py @@ -1,4 +1,4 @@ -"""Tests for LuminescenceCapability.""" +"""Tests for Luminescence.""" import unittest from typing import List, Optional @@ -7,7 +7,7 @@ from pylabrobot.capabilities.plate_reading.luminescence.chatterbox import ( LuminescenceChatterboxBackend, ) -from pylabrobot.capabilities.plate_reading.luminescence.luminescence import LuminescenceCapability +from pylabrobot.capabilities.plate_reading.luminescence.luminescence import Luminescence from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult from pylabrobot.resources.plate import Plate from pylabrobot.resources.utils import create_ordered_items_2d @@ -60,10 +60,10 @@ async def read_luminescence( return [LuminescenceResult(data=data, temperature=25.0, timestamp=0.0)] -class TestLuminescenceCapability(unittest.IsolatedAsyncioTestCase): +class TestLuminescence(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self): self.backend = RecordingLuminescenceBackend() - self.cap = LuminescenceCapability(backend=self.backend) + self.cap = Luminescence(backend=self.backend) await self.cap._on_setup() self.plate = _test_plate() @@ -84,7 +84,7 @@ async def test_read_all_wells(self): async def test_read_requires_setup(self): backend = RecordingLuminescenceBackend() - cap = LuminescenceCapability(backend=backend) + cap = Luminescence(backend=backend) with self.assertRaises(RuntimeError): await cap.read(plate=self.plate, focal_height=13.0) @@ -92,7 +92,7 @@ async def test_read_requires_setup(self): class TestLuminescenceChatterbox(unittest.IsolatedAsyncioTestCase): async def test_chatterbox_read(self): backend = LuminescenceChatterboxBackend() - cap = LuminescenceCapability(backend=backend) + cap = Luminescence(backend=backend) await cap._on_setup() plate = _test_plate() diff --git a/pylabrobot/capabilities/pumping/__init__.py b/pylabrobot/capabilities/pumping/__init__.py index 4e3c3a32ec0..6c2f36bcc9b 100644 --- a/pylabrobot/capabilities/pumping/__init__.py +++ b/pylabrobot/capabilities/pumping/__init__.py @@ -2,4 +2,4 @@ from .calibration import PumpCalibration from .chatterbox import PumpChatterboxBackend from .errors import NotCalibratedError -from .pumping import PumpingCapability +from .pumping import Pump diff --git a/pylabrobot/capabilities/pumping/pumping.py b/pylabrobot/capabilities/pumping/pumping.py index 1e83cc4556e..82554ff55f2 100644 --- a/pylabrobot/capabilities/pumping/pumping.py +++ b/pylabrobot/capabilities/pumping/pumping.py @@ -8,7 +8,7 @@ from .calibration import PumpCalibration -class PumpingCapability(Capability): +class Pump(Capability): """Single-pump capability.""" def __init__( diff --git a/pylabrobot/capabilities/pumping/pumping_tests.py b/pylabrobot/capabilities/pumping/pumping_tests.py index 144e506bca5..e862eb9a072 100644 --- a/pylabrobot/capabilities/pumping/pumping_tests.py +++ b/pylabrobot/capabilities/pumping/pumping_tests.py @@ -4,10 +4,10 @@ from pylabrobot.capabilities.pumping.backend import PumpBackend from pylabrobot.capabilities.pumping.calibration import PumpCalibration from pylabrobot.capabilities.pumping.errors import NotCalibratedError -from pylabrobot.capabilities.pumping.pumping import PumpingCapability +from pylabrobot.capabilities.pumping.pumping import Pump -class TestPumpingCapability(unittest.IsolatedAsyncioTestCase): +class TestPump(unittest.IsolatedAsyncioTestCase): def setUp(self): self.mock_backend = Mock(spec=PumpBackend) self.mock_backend.run_revolutions = AsyncMock() @@ -16,7 +16,7 @@ def setUp(self): self.test_calibration = PumpCalibration.load_calibration(1, num_items=1) async def _make_cap(self, calibration=None): - cap = PumpingCapability(backend=self.mock_backend, calibration=calibration) + cap = Pump(backend=self.mock_backend, calibration=calibration) await cap._on_setup() return cap @@ -70,7 +70,7 @@ async def test_pump_volume_no_calibration(self): await cap.pump_volume(speed=1, volume=1) async def test_not_setup_raises(self): - cap = PumpingCapability(backend=self.mock_backend) + cap = Pump(backend=self.mock_backend) with self.assertRaises(RuntimeError): await cap.run_continuously(speed=1) diff --git a/pylabrobot/capabilities/sealing/__init__.py b/pylabrobot/capabilities/sealing/__init__.py index 368729a121d..ff78148e8cc 100644 --- a/pylabrobot/capabilities/sealing/__init__.py +++ b/pylabrobot/capabilities/sealing/__init__.py @@ -1,2 +1,2 @@ from .backend import SealerBackend -from .sealing import SealingCapability +from .sealing import Sealer diff --git a/pylabrobot/capabilities/sealing/chatterbox.py b/pylabrobot/capabilities/sealing/chatterbox.py new file mode 100644 index 00000000000..7a94ee580de --- /dev/null +++ b/pylabrobot/capabilities/sealing/chatterbox.py @@ -0,0 +1,18 @@ +import logging + +from .backend import SealerBackend + +logger = logging.getLogger(__name__) + + +class SealerChatterboxBackend(SealerBackend): + """Chatterbox backend for device-free testing.""" + + async def seal(self, temperature: int, duration: float): + logger.info("Sealing at %s C for %s seconds.", temperature, duration) + + async def open(self): + logger.info("Opening sealer shuttle.") + + async def close(self): + logger.info("Closing sealer shuttle.") diff --git a/pylabrobot/capabilities/sealing/sealing.py b/pylabrobot/capabilities/sealing/sealing.py index ac8833a49fc..14f2a4b1ed8 100644 --- a/pylabrobot/capabilities/sealing/sealing.py +++ b/pylabrobot/capabilities/sealing/sealing.py @@ -3,7 +3,7 @@ from .backend import SealerBackend -class SealingCapability(Capability): +class Sealer(Capability): """Sealing capability.""" def __init__(self, backend: SealerBackend): diff --git a/pylabrobot/capabilities/shaking/__init__.py b/pylabrobot/capabilities/shaking/__init__.py index 55decd44c19..b01f891a35b 100644 --- a/pylabrobot/capabilities/shaking/__init__.py +++ b/pylabrobot/capabilities/shaking/__init__.py @@ -1,2 +1,2 @@ from .backend import ShakerBackend -from .shaking import ShakingCapability +from .shaking import Shaker diff --git a/pylabrobot/capabilities/shaking/chatterbox.py b/pylabrobot/capabilities/shaking/chatterbox.py new file mode 100644 index 00000000000..a29b6bcf66c --- /dev/null +++ b/pylabrobot/capabilities/shaking/chatterbox.py @@ -0,0 +1,25 @@ +import logging + +from .backend import ShakerBackend + +logger = logging.getLogger(__name__) + + +class ShakerChatterboxBackend(ShakerBackend): + """Chatterbox backend for device-free testing.""" + + @property + def supports_locking(self) -> bool: + return True + + async def start_shaking(self, speed: float): + logger.info("Starting shaking at %s RPM.", speed) + + async def stop_shaking(self): + logger.info("Stopping shaking.") + + async def lock_plate(self): + logger.info("Locking plate.") + + async def unlock_plate(self): + logger.info("Unlocking plate.") diff --git a/pylabrobot/capabilities/shaking/shaking.py b/pylabrobot/capabilities/shaking/shaking.py index 204953dfa30..88e2147f36e 100644 --- a/pylabrobot/capabilities/shaking/shaking.py +++ b/pylabrobot/capabilities/shaking/shaking.py @@ -6,7 +6,7 @@ from .backend import ShakerBackend -class ShakingCapability(Capability): +class Shaker(Capability): """Shaking capability.""" def __init__(self, backend: ShakerBackend): diff --git a/pylabrobot/capabilities/temperature_controlling/__init__.py b/pylabrobot/capabilities/temperature_controlling/__init__.py index 705339cac3b..ad6071ef2d5 100644 --- a/pylabrobot/capabilities/temperature_controlling/__init__.py +++ b/pylabrobot/capabilities/temperature_controlling/__init__.py @@ -1,2 +1,2 @@ from .backend import TemperatureControllerBackend -from .temperature_controller import TemperatureControlCapability +from .temperature_controller import TemperatureController diff --git a/pylabrobot/capabilities/temperature_controlling/chatterbox.py b/pylabrobot/capabilities/temperature_controlling/chatterbox.py new file mode 100644 index 00000000000..25f9529947d --- /dev/null +++ b/pylabrobot/capabilities/temperature_controlling/chatterbox.py @@ -0,0 +1,27 @@ +import logging + +from .backend import TemperatureControllerBackend + +logger = logging.getLogger(__name__) + + +class TemperatureControllerChatterboxBackend(TemperatureControllerBackend): + """Chatterbox backend for device-free testing.""" + + def __init__(self): + self._temperature = 22.0 + + @property + def supports_active_cooling(self) -> bool: + return True + + async def set_temperature(self, temperature: float): + logger.info("Setting temperature to %s C.", temperature) + self._temperature = temperature + + async def request_current_temperature(self) -> float: + return self._temperature + + async def deactivate(self): + logger.info("Deactivating temperature controller.") + self._temperature = 22.0 diff --git a/pylabrobot/capabilities/temperature_controlling/temperature_controller.py b/pylabrobot/capabilities/temperature_controlling/temperature_controller.py index 250573ac793..0a9cd1bdb15 100644 --- a/pylabrobot/capabilities/temperature_controlling/temperature_controller.py +++ b/pylabrobot/capabilities/temperature_controlling/temperature_controller.py @@ -7,7 +7,7 @@ from .backend import TemperatureControllerBackend -class TemperatureControlCapability(Capability): +class TemperatureController(Capability): """Temperature control capability, for heating or cooling.""" def __init__(self, backend: TemperatureControllerBackend): diff --git a/pylabrobot/capabilities/tilting/__init__.py b/pylabrobot/capabilities/tilting/__init__.py index 080340cde41..e01d031b758 100644 --- a/pylabrobot/capabilities/tilting/__init__.py +++ b/pylabrobot/capabilities/tilting/__init__.py @@ -1,2 +1,2 @@ from .backend import TilterBackend, TiltModuleError -from .tilting import TiltingCapability +from .tilting import Tilter diff --git a/pylabrobot/capabilities/tilting/chatterbox.py b/pylabrobot/capabilities/tilting/chatterbox.py new file mode 100644 index 00000000000..ef94217ca89 --- /dev/null +++ b/pylabrobot/capabilities/tilting/chatterbox.py @@ -0,0 +1,12 @@ +import logging + +from .backend import TilterBackend + +logger = logging.getLogger(__name__) + + +class TilterChatterboxBackend(TilterBackend): + """Chatterbox backend for device-free testing.""" + + async def set_angle(self, angle: float): + logger.info("Setting tilt angle to %s degrees.", angle) diff --git a/pylabrobot/capabilities/tilting/tilting.py b/pylabrobot/capabilities/tilting/tilting.py index 41d13dbc256..3bf500aea63 100644 --- a/pylabrobot/capabilities/tilting/tilting.py +++ b/pylabrobot/capabilities/tilting/tilting.py @@ -3,7 +3,7 @@ from .backend import TilterBackend -class TiltingCapability(Capability): +class Tilter(Capability): """Tilting capability.""" def __init__(self, backend: TilterBackend): diff --git a/pylabrobot/capabilities/weighing/__init__.py b/pylabrobot/capabilities/weighing/__init__.py index 2e7a3ab77f6..3dfa7d99758 100644 --- a/pylabrobot/capabilities/weighing/__init__.py +++ b/pylabrobot/capabilities/weighing/__init__.py @@ -1,2 +1,2 @@ from .backend import ScaleBackend -from .weighing import WeighingCapability +from .weighing import Scale diff --git a/pylabrobot/capabilities/weighing/chatterbox.py b/pylabrobot/capabilities/weighing/chatterbox.py new file mode 100644 index 00000000000..440a1011bf6 --- /dev/null +++ b/pylabrobot/capabilities/weighing/chatterbox.py @@ -0,0 +1,23 @@ +import logging + +from .backend import ScaleBackend + +logger = logging.getLogger(__name__) + + +class ScaleChatterboxBackend(ScaleBackend): + """Chatterbox backend for device-free testing.""" + + def __init__(self): + self._weight = 0.0 + + async def zero(self): + logger.info("Zeroing scale.") + self._weight = 0.0 + + async def tare(self): + logger.info("Taring scale.") + self._weight = 0.0 + + async def read_weight(self) -> float: + return self._weight diff --git a/pylabrobot/capabilities/weighing/weighing.py b/pylabrobot/capabilities/weighing/weighing.py index cd26289fbc1..a88737bd5ce 100644 --- a/pylabrobot/capabilities/weighing/weighing.py +++ b/pylabrobot/capabilities/weighing/weighing.py @@ -3,7 +3,7 @@ from .backend import ScaleBackend -class WeighingCapability(Capability): +class Scale(Capability): """Weighing capability.""" def __init__(self, backend: ScaleBackend): diff --git a/pylabrobot/cole_parmer/masterflex_backend.py b/pylabrobot/cole_parmer/masterflex_backend.py index 6c0ab10f407..eb3c7f4f712 100644 --- a/pylabrobot/cole_parmer/masterflex_backend.py +++ b/pylabrobot/cole_parmer/masterflex_backend.py @@ -10,7 +10,7 @@ from pylabrobot.capabilities.pumping.backend import PumpBackend from pylabrobot.capabilities.pumping.calibration import PumpCalibration -from pylabrobot.capabilities.pumping.pumping import PumpingCapability +from pylabrobot.capabilities.pumping.pumping import Pump from pylabrobot.device import Device, Driver from pylabrobot.io.serial import Serial @@ -73,12 +73,12 @@ class MasterflexBackend(PumpBackend): """Pump capability backend for Masterflex L/S pumps.""" def __init__(self, driver: MasterflexDriver): - self._driver = driver + self.driver = driver async def run_revolutions(self, num_revolutions: float): num_revolutions = round(num_revolutions, 2) cmd = f"V{num_revolutions}G" - await self._driver.send_command(cmd) + await self.driver.send_command(cmd) async def run_continuously(self, speed: float): if speed == 0: @@ -88,14 +88,14 @@ async def run_continuously(self, speed: float): direction = "+" if speed > 0 else "-" speed_int = int(abs(speed)) cmd = f"S{direction}{speed_int}G0" - await self._driver.send_command(cmd) + await self.driver.send_command(cmd) async def halt(self): - await self._driver.send_command("H") + await self.driver.send_command("H") def serialize(self): return { - "com_port": self._driver.com_port, + "com_port": self.driver.com_port, } @@ -109,6 +109,6 @@ def __init__( ): driver = MasterflexDriver(com_port=com_port) super().__init__(driver=driver) - self._driver: MasterflexDriver - self.pumping = PumpingCapability(backend=MasterflexBackend(driver), calibration=calibration) + self.driver: MasterflexDriver + self.pumping = Pump(backend=MasterflexBackend(driver), calibration=calibration) self._capabilities = [self.pumping] diff --git a/pylabrobot/device.py b/pylabrobot/device.py index c78c5cf762a..fda7ba3f401 100644 --- a/pylabrobot/device.py +++ b/pylabrobot/device.py @@ -82,7 +82,7 @@ class Device(SerializableMixin, ABC): """Abstract base class for device frontends.""" def __init__(self, driver: Driver): - self._driver = driver + self.driver = driver self._setup_finished = False self._capabilities: List[Capability] = [] @@ -91,7 +91,7 @@ def setup_finished(self) -> bool: return self._setup_finished def serialize(self) -> dict: - return {"driver": self._driver.serialize()} + return {"driver": self.driver.serialize()} @classmethod def deserialize(cls, data: dict): @@ -102,7 +102,7 @@ def deserialize(cls, data: dict): return cls(**data_copy) async def setup(self): - await self._driver.setup() + await self.driver.setup() for cap in self._capabilities: await cap._on_setup() self._setup_finished = True @@ -111,7 +111,7 @@ async def setup(self): async def stop(self): for cap in reversed(self._capabilities): await cap._on_stop() - await self._driver.stop() + await self.driver.stop() self._setup_finished = False async def __aenter__(self): diff --git a/pylabrobot/hamilton/heater_shaker/backend.py b/pylabrobot/hamilton/heater_shaker/backend.py index 0ce32a16157..fd0434db9c7 100644 --- a/pylabrobot/hamilton/heater_shaker/backend.py +++ b/pylabrobot/hamilton/heater_shaker/backend.py @@ -51,10 +51,10 @@ class HamiltonHeaterShakerShakerBackend(ShakerBackend): """Translates ShakerBackend interface into Hamilton Heater Shaker driver commands.""" def __init__(self, driver: HamiltonHeaterShakerDriver) -> None: - self._driver = driver + self.driver = driver async def _on_setup(self): - await self._driver.send_command("SI") + await self.driver.send_command("SI") async def start_shaking( self, @@ -83,11 +83,11 @@ async def stop_shaking(self): await self._wait_for_stop() async def request_is_shaking(self) -> bool: - response = await self._driver.send_command("RD") + response = await self.driver.send_command("RD") return response.endswith("1") async def _move_plate_lock(self, position: PlateLockPosition): - return await self._driver.send_command("LP", lp=position.value) + return await self.driver.send_command("LP", lp=position.value) @property def supports_locking(self) -> bool: @@ -102,13 +102,13 @@ async def unlock_plate(self): async def _start_shaking(self, direction: int, speed: int, acceleration: int): speed_str = str(speed).zfill(4) acceleration_str = str(acceleration).zfill(5) - return await self._driver.send_command("SB", st=direction, sv=speed_str, sr=acceleration_str) + return await self.driver.send_command("SB", st=direction, sv=speed_str, sr=acceleration_str) async def _stop_shaking(self): - return await self._driver.send_command("SC") + return await self.driver.send_command("SC") async def _wait_for_stop(self): - return await self._driver.send_command("SW") + return await self.driver.send_command("SW") class HamiltonHeaterShakerTemperatureBackend(TemperatureControllerBackend): @@ -116,10 +116,10 @@ class HamiltonHeaterShakerTemperatureBackend(TemperatureControllerBackend): commands.""" def __init__(self, driver: HamiltonHeaterShakerDriver) -> None: - self._driver = driver + self.driver = driver async def _on_setup(self): - await self._driver.send_command("LI") + await self.driver.send_command("LI") @property def supports_active_cooling(self) -> bool: @@ -128,10 +128,10 @@ def supports_active_cooling(self) -> bool: async def set_temperature(self, temperature: float): assert 0 < temperature <= 105 temp_str = f"{round(10 * temperature):04d}" - return await self._driver.send_command("TA", ta=temp_str) + return await self.driver.send_command("TA", ta=temp_str) async def _request_current_temperature(self) -> Dict[str, float]: - response = await self._driver.send_command("RT") + response = await self.driver.send_command("RT") response = response.split("rt")[1] middle_temp = float(str(response).split(" ")[0].strip("+")) / 10 edge_temp = float(str(response).split(" ")[1].strip("+")) / 10 @@ -146,4 +146,4 @@ async def request_edge_temperature(self) -> float: return response["edge"] async def deactivate(self): - return await self._driver.send_command("TO") + return await self.driver.send_command("TO") diff --git a/pylabrobot/hamilton/heater_shaker/heater_shaker.py b/pylabrobot/hamilton/heater_shaker/heater_shaker.py index 363f0193cb7..fb6139ac24d 100644 --- a/pylabrobot/hamilton/heater_shaker/heater_shaker.py +++ b/pylabrobot/hamilton/heater_shaker/heater_shaker.py @@ -1,7 +1,7 @@ from typing import Optional -from pylabrobot.capabilities.shaking import ShakingCapability -from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.capabilities.shaking import Shaker +from pylabrobot.capabilities.temperature_controlling import TemperatureController from pylabrobot.device import Device from pylabrobot.resources import Coordinate from pylabrobot.resources.carrier import PlateHolder @@ -44,9 +44,9 @@ def __init__( model=model, ) Device.__init__(self, driver=driver) - self._driver: HamiltonHeaterShakerDriver = driver - self.tc = TemperatureControlCapability(backend=HamiltonHeaterShakerTemperatureBackend(driver)) - self.shaker = ShakingCapability(backend=HamiltonHeaterShakerShakerBackend(driver)) + self.driver: HamiltonHeaterShakerDriver = driver + self.tc = TemperatureController(backend=HamiltonHeaterShakerTemperatureBackend(driver)) + self.shaker = Shaker(backend=HamiltonHeaterShakerShakerBackend(driver)) self._capabilities = [self.tc, self.shaker] def serialize(self) -> dict: diff --git a/pylabrobot/hamilton/liquid_handlers/star/autoload.py b/pylabrobot/hamilton/liquid_handlers/star/autoload.py index 0af65514609..f316ac9e904 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/autoload.py +++ b/pylabrobot/hamilton/liquid_handlers/star/autoload.py @@ -49,26 +49,29 @@ class STARAutoload: } def __init__(self, driver: "STARDriver", instrument_size_slots: int = 54): - self._driver = driver + self.driver = driver self._instrument_size_slots = instrument_size_slots self._default_1d_symbology: Barcode1DSymbology = "Code 128 (Subset B and C)" - # -- initialization -------------------------------------------------------- + # -- lifecycle ------------------------------------------------------------- - async def initialize(self): + async def _on_setup(self): """Initialize Auto load module (C0:II).""" - return await self._driver.send_command(module="C0", command="II") + await self.driver.send_command(module="C0", command="II") + + async def _on_stop(self): + pass async def request_initialization_status(self) -> bool: """Request autoload initialization status (I0:QW).""" - resp = await self._driver.send_command(module="I0", command="QW", fmt="qw#") + resp = await self.driver.send_command(module="I0", command="QW", fmt="qw#") return resp is not None and resp["qw"] == 1 # -- z-position safety ----------------------------------------------------- async def move_to_safe_z_position(self): """Move autoload carrier handling wheel to safe Z position (C0:IV).""" - return await self._driver.send_command(module="C0", command="IV") + return await self.driver.send_command(module="C0", command="IV") # -- position queries ------------------------------------------------------ @@ -78,7 +81,7 @@ async def request_track(self) -> int: Returns: track (0..54) """ - resp = await self._driver.send_command(module="C0", command="QA", fmt="qa##") + resp = await self.driver.send_command(module="C0", command="QA", fmt="qa##") return int(resp["qa"]) async def request_type(self) -> str: @@ -94,7 +97,7 @@ async def request_type(self) -> str: 2: "ML-STAR with 2D Barcode Scanner", } - resp = await self._driver.send_command(module="C0", command="CQ", fmt="cq#") + resp = await self.driver.send_command(module="C0", command="CQ", fmt="cq#") resp = autoload_type_dict[resp["cq"]] if resp["cq"] in autoload_type_dict else resp["cq"] return str(resp) @@ -130,7 +133,7 @@ async def request_presence_of_carriers_on_deck(self) -> List[int]: Returns: Sorted list of deck rail positions where carriers are present. """ - resp = await self._driver.send_command(module="C0", command="RC") + resp = await self.driver.send_command(module="C0", command="RC") ce_resp = resp.split("ce")[-1] @@ -142,7 +145,7 @@ async def request_presence_of_carriers_on_loading_tray(self) -> List[int]: Returns: Sorted list of loading-tray positions where carriers are present. """ - resp = await self._driver.send_command(module="C0", command="CS") + resp = await self.driver.send_command(module="C0", command="CS") if "cd" not in resp: raise ValueError(f"CD field missing: {resp!r}") @@ -161,17 +164,19 @@ async def request_presence_of_single_carrier_on_loading_tray(self, track: int) - True if a carrier is detected at the given track; False otherwise. """ - assert 1 <= track <= 54, "track must be between 1 and 54" + if not (1 <= track <= 54): + raise ValueError("track must be between 1 and 54") track_str = str(track).zfill(2) - resp = await self._driver.send_command( + resp = await self.driver.send_command( module="C0", command="CT", fmt="ct#", cp=track_str, ) - assert resp is not None + if resp is None: + raise RuntimeError("Expected a response from send_command for CT, got None") return int(resp["ct"]) == 1 @@ -180,12 +185,13 @@ async def request_presence_of_single_carrier_on_loading_tray(self, track: int) - async def move_to_track(self, track: int): """Move autoload to specific track position (I0:XP).""" - assert 1 <= track <= 54, "track must be between 1 and 54" + if not (1 <= track <= 54): + raise ValueError("track must be between 1 and 54") await self.move_to_safe_z_position() track_no_as_safe_str = str(track).zfill(2) - return await self._driver.send_command(module="I0", command="XP", xp=track_no_as_safe_str) + return await self.driver.send_command(module="I0", command="XP", xp=track_no_as_safe_str) async def park(self): """Park autoload to max position (I0:XP).""" @@ -194,7 +200,7 @@ async def park(self): await self.move_to_safe_z_position() - return await self._driver.send_command(module="I0", command="XP", xp=max_x_pos) + return await self.driver.send_command(module="I0", command="XP", xp=max_x_pos) # -- belt operations ------------------------------------------------------- @@ -211,7 +217,7 @@ async def take_carrier_out_to_belt(self, carrier_end_rail: int): if not carrier_on_loading_tray: try: - await self._driver.send_command( + await self.driver.send_command( module="C0", command="CN", cp=str(carrier_end_rail).zfill(2), @@ -227,7 +233,7 @@ async def take_carrier_out_to_belt(self, carrier_end_rail: int): async def unload_carrier_after_barcode_scanning(self): """Unload carrier back to loading tray after barcode scanning (C0:CA).""" try: - resp = await self._driver.send_command( + resp = await self.driver.send_command( module="C0", command="CA", ) @@ -254,12 +260,30 @@ async def load_carrier_from_belt( Optionally reads container barcodes during the load. """ - assert barcode_reading_direction in ["horizontal", "vertical"] - assert 0 <= reading_position_of_first_barcode <= 470 - assert 0 <= no_container_per_carrier <= 32 - assert 0 <= distance_between_containers <= 470 - assert 0.1 <= width_of_reading_window <= 99.9 - assert 1.5 <= reading_speed <= 160.0 + if barcode_reading_direction not in ["horizontal", "vertical"]: + raise ValueError( + f"barcode_reading_direction must be 'horizontal' or 'vertical', " + f"got {barcode_reading_direction!r}" + ) + if not (0 <= reading_position_of_first_barcode <= 470): + raise ValueError( + f"reading_position_of_first_barcode must be between 0 and 470, " + f"got {reading_position_of_first_barcode}" + ) + if not (0 <= no_container_per_carrier <= 32): + raise ValueError( + f"no_container_per_carrier must be between 0 and 32, got {no_container_per_carrier}" + ) + if not (0 <= distance_between_containers <= 470): + raise ValueError( + f"distance_between_containers must be between 0 and 470, got {distance_between_containers}" + ) + if not (0.1 <= width_of_reading_window <= 99.9): + raise ValueError( + f"width_of_reading_window must be between 0.1 and 99.9, got {width_of_reading_window}" + ) + if not (1.5 <= reading_speed <= 160.0): + raise ValueError(f"reading_speed must be between 1.5 and 160.0, got {reading_speed}") barcode_reading_direction_dict = { "vertical": "0", @@ -268,7 +292,8 @@ async def load_carrier_from_belt( if barcode_symbology is None: barcode_symbology = self._default_1d_symbology - assert barcode_symbology is not None + if barcode_symbology is None: + raise RuntimeError("barcode_symbology is None after fallback to default") no_container_per_carrier_str = str(no_container_per_carrier).zfill(2) reading_position_of_first_barcode_str = str( @@ -289,7 +314,7 @@ async def load_carrier_from_belt( self._default_1d_symbology = barcode_symbology try: - resp = await self._driver.send_command( + resp = await self.driver.send_command( module="C0", command="CL", bd=barcode_reading_direction_dict[barcode_reading_direction], @@ -306,17 +331,19 @@ async def load_carrier_from_belt( if park_autoload_after: await self.park() - assert isinstance(resp, str), f"Response is not a string: {resp!r}" + if not isinstance(resp, str): + raise RuntimeError(f"Expected a string response from CL command, got {resp!r}") barcode_dict: Dict[int, Optional[Barcode]] = {} if barcode_reading: resp_list = resp.split("bb/")[-1].split("/") # remove header - assert len(resp_list) == no_container_per_carrier, ( - f"Number of barcodes read ({len(resp_list)}) does not match " - f"expected number ({no_container_per_carrier})" - ) + if len(resp_list) != no_container_per_carrier: + raise ValueError( + f"Number of barcodes read ({len(resp_list)}) does not match " + f"expected number ({no_container_per_carrier})" + ) for i in range(0, no_container_per_carrier): if resp_list[i] == "00": barcode_dict[i] = None @@ -338,9 +365,10 @@ async def set_1d_barcode_type( if barcode_symbology is None: barcode_symbology = self._default_1d_symbology - assert barcode_symbology is not None + if barcode_symbology is None: + raise RuntimeError("barcode_symbology is None after fallback to default") - await self._driver.send_command( + await self.driver.send_command( module="C0", command="CB", bt=self.barcode_1d_symbology_dict[barcode_symbology], @@ -366,17 +394,25 @@ async def load_carrier_from_tray_and_scan_carrier_barcode( if barcode_symbology is None: barcode_symbology = self._default_1d_symbology - assert barcode_symbology is not None + if barcode_symbology is None: + raise RuntimeError("barcode_symbology is None after fallback to default") carrier_end_rail_str = str(carrier_end_rail).zfill(2) - assert 1 <= int(carrier_end_rail_str) <= 54 - assert 0 <= barcode_position <= 470 - assert 0.1 <= barcode_reading_window_width <= 99.9 - assert 1.5 <= reading_speed <= 160.0 + if not (1 <= int(carrier_end_rail_str) <= 54): + raise ValueError(f"carrier_end_rail must be between 1 and 54, got {carrier_end_rail}") + if not (0 <= barcode_position <= 470): + raise ValueError(f"barcode_position must be between 0 and 470, got {barcode_position}") + if not (0.1 <= barcode_reading_window_width <= 99.9): + raise ValueError( + f"barcode_reading_window_width must be between 0.1 and 99.9, " + f"got {barcode_reading_window_width}" + ) + if not (1.5 <= reading_speed <= 160.0): + raise ValueError(f"reading_speed must be between 1.5 and 160.0, got {reading_speed}") try: - resp = await self._driver.send_command( + resp = await self.driver.send_command( module="C0", command="CI", cp=carrier_end_rail_str, @@ -432,7 +468,8 @@ async def load_carrier( if barcode_symbology is None: barcode_symbology = self._default_1d_symbology - assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" + if not (1 <= carrier_end_rail <= 54): + raise ValueError("carrier loading rail must be between 1 and 54") # Determine presence of carrier at defined position presence_check = await self.request_presence_of_single_carrier_on_loading_tray(carrier_end_rail) @@ -490,11 +527,12 @@ async def unload_carrier( carrier_end_rail: End rail position of the carrier (1-54). """ - assert 1 <= carrier_end_rail <= 54, "carrier loading rail must be between 1 and 54" + if not (1 <= carrier_end_rail <= 54): + raise ValueError("carrier loading rail must be between 1 and 54") carrier_end_rail_str = str(carrier_end_rail).zfill(2) - resp = await self._driver.send_command( + resp = await self.driver.send_command( module="C0", command="CR", cp=carrier_end_rail_str, @@ -515,8 +553,10 @@ async def set_loading_indicators(self, bit_pattern: List[bool], blink_pattern: L blink_pattern: Blinking if True, steady otherwise. Length 54. """ - assert len(bit_pattern) == 54, "bit pattern must be length 54" - assert len(blink_pattern) == 54, "bit pattern must be length 54" + if len(bit_pattern) != 54: + raise ValueError(f"bit_pattern must be length 54, got {len(bit_pattern)}") + if len(blink_pattern) != 54: + raise ValueError(f"blink_pattern must be length 54, got {len(blink_pattern)}") def pattern2hex(pattern: List[bool]) -> str: bit_string = "".join(["1" if x else "0" for x in pattern]) @@ -525,7 +565,7 @@ def pattern2hex(pattern: List[bool]) -> str: bit_pattern_hex = pattern2hex(bit_pattern) blink_pattern_hex = pattern2hex(blink_pattern) - return await self._driver.send_command( + return await self.driver.send_command( module="C0", command="CP", cl=bit_pattern_hex, @@ -539,7 +579,7 @@ async def set_carrier_monitoring(self, should_monitor: bool = False): should_monitor: whether carrier should be monitored. """ - return await self._driver.send_command(module="C0", command="CU", cu=should_monitor) + return await self.driver.send_command(module="C0", command="CU", cu=should_monitor) async def verify_and_wait_for_carriers( self, diff --git a/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py b/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py index d60aa406fdd..c42ef11b87f 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py +++ b/pylabrobot/hamilton/liquid_handlers/star/chatterbox.py @@ -2,12 +2,19 @@ from typing import List, Optional +from .autoload import STARAutoload +from .cover import STARCover from .driver import ( DriveConfiguration, ExtendedConfiguration, MachineConfiguration, STARDriver, ) +from .head96_backend import STARHead96Backend +from .iswap import iSWAPBackend +from .pip_backend import STARPIPBackend +from .wash_station import STARWashStation +from .x_arm import STARXArm _DEFAULT_MACHINE_CONF = MachineConfiguration( pip_type_1000ul=True, @@ -54,22 +61,16 @@ async def setup(self): self.machine_conf = self._machine_configuration self.extended_conf = self._extended_configuration - from .pip_backend import STARPIPBackend - self.pip = STARPIPBackend(self) self._channels_minimum_y_spacing = [9.0] * self._num_channels if self.extended_conf.left_x_drive.core_96_head_installed: - from .head96_backend import STARHead96Backend - self.head96 = STARHead96Backend(self) else: self.head96 = None if self.extended_conf.left_x_drive.iswap_installed: - from .iswap import iSWAPBackend - self.iswap = iSWAPBackend(driver=self) self.iswap._version = "chatterbox" self.iswap._parked = True @@ -77,8 +78,6 @@ async def setup(self): self.iswap = None if self.machine_conf.auto_load_installed: - from .autoload import STARAutoload - self.autoload = STARAutoload( driver=self, instrument_size_slots=self.extended_conf.instrument_size_slots, @@ -86,27 +85,26 @@ async def setup(self): else: self.autoload = None - from .x_arm import STARXArm - self.left_x_arm = STARXArm(driver=self, side="left") if self.extended_conf.right_x_drive_large: self.right_x_arm = STARXArm(driver=self, side="right") else: self.right_x_arm = None - from .cover import STARCover - self.cover = STARCover(driver=self) if (self.machine_conf.wash_station_1_installed or self.machine_conf.wash_station_2_installed): - from .wash_station import STARWashStation - self.wash_station = STARWashStation(driver=self) else: self.wash_station = None + for sub in self._subsystems: + await sub._on_setup() + async def stop(self): + for sub in reversed(self._subsystems): + await sub._on_stop() self.machine_conf = None self.extended_conf = None self._channels_minimum_y_spacing = [] diff --git a/pylabrobot/hamilton/liquid_handlers/star/core.py b/pylabrobot/hamilton/liquid_handlers/star/core.py index 2a51a4ef049..d6196e6f5f7 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/core.py +++ b/pylabrobot/hamilton/liquid_handlers/star/core.py @@ -1,12 +1,16 @@ +from __future__ import annotations + from dataclasses import dataclass -from typing import Optional +from typing import TYPE_CHECKING, Optional from pylabrobot.arms.backend import GripperArmBackend from pylabrobot.arms.standard import GripperLocation from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver from pylabrobot.resources import Coordinate +if TYPE_CHECKING: + from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver + class CoreGripper(GripperArmBackend): """Backend for Hamilton CoRe gripper tools. diff --git a/pylabrobot/hamilton/liquid_handlers/star/cover.py b/pylabrobot/hamilton/liquid_handlers/star/cover.py index 0bdce3335ef..b5b59f6b0cb 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/cover.py +++ b/pylabrobot/hamilton/liquid_handlers/star/cover.py @@ -19,23 +19,33 @@ class STARCover: """ def __init__(self, driver: "STARDriver"): - self._driver = driver + self.driver = driver + + # -- lifecycle ------------------------------------------------------------- + + async def _on_setup(self): + pass + + async def _on_stop(self): + pass + + # -- commands -------------------------------------------------------------- async def lock(self): """Lock cover (C0:CO).""" - return await self._driver.send_command(module="C0", command="CO") + return await self.driver.send_command(module="C0", command="CO") async def unlock(self): """Unlock cover (C0:HO).""" - return await self._driver.send_command(module="C0", command="HO") + return await self.driver.send_command(module="C0", command="HO") async def disable(self): """Disable cover control (C0:CD).""" - return await self._driver.send_command(module="C0", command="CD") + return await self.driver.send_command(module="C0", command="CD") async def enable(self): """Enable cover control (C0:CE).""" - return await self._driver.send_command(module="C0", command="CE") + return await self.driver.send_command(module="C0", command="CE") async def set_output(self, output: int = 1): """Set cover output (C0:OS). @@ -43,8 +53,9 @@ async def set_output(self, output: int = 1): Args: output: 1 = cover lock; 2 = reserve out; 3 = reserve out. """ - assert 1 <= output <= 3, "output must be between 1 and 3" - return await self._driver.send_command(module="C0", command="OS", on=output) + if not 1 <= output <= 3: + raise ValueError("output must be between 1 and 3") + return await self.driver.send_command(module="C0", command="OS", on=output) async def reset_output(self, output: int = 1): """Reset output (C0:QS). @@ -52,8 +63,9 @@ async def reset_output(self, output: int = 1): Args: output: 1 = cover lock; 2 = reserve out; 3 = reserve out. """ - assert 1 <= output <= 3, "output must be between 1 and 3" - return await self._driver.send_command(module="C0", command="QS", on=output, fmt="#") + if not 1 <= output <= 3: + raise ValueError("output must be between 1 and 3") + return await self.driver.send_command(module="C0", command="QS", on=output, fmt="#") async def is_open(self) -> bool: """Request whether the cover is open (C0:QC). @@ -61,5 +73,5 @@ async def is_open(self) -> bool: Returns: True if the cover is open. """ - resp = await self._driver.send_command(module="C0", command="QC", fmt="qc#") + resp = await self.driver.send_command(module="C0", command="QC", fmt="qc#") return bool(resp["qc"]) diff --git a/pylabrobot/hamilton/liquid_handlers/star/driver.py b/pylabrobot/hamilton/liquid_handlers/star/driver.py index 52ebb305b77..2947fd72f09 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/driver.py +++ b/pylabrobot/hamilton/liquid_handlers/star/driver.py @@ -6,7 +6,7 @@ import math import re from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Dict, List, Optional +from typing import Any, Dict, List, Optional from pylabrobot.capabilities.liquid_handling.head96_backend import Head96Backend from pylabrobot.capabilities.liquid_handling.pip_backend import PIPBackend @@ -18,12 +18,13 @@ ) from pylabrobot.resources.hamilton import TipPickupMethod, TipSize -if TYPE_CHECKING: - from .autoload import STARAutoload - from .cover import STARCover - from .iswap import iSWAPBackend - from .wash_station import STARWashStation - from .x_arm import STARXArm +from .autoload import STARAutoload +from .cover import STARCover +from .head96_backend import STARHead96Backend +from .iswap import iSWAPBackend +from .pip_backend import STARPIPBackend +from .wash_station import STARWashStation +from .x_arm import STARXArm # --------------------------------------------------------------------------- @@ -193,6 +194,11 @@ def check_fw_string_error(self, resp: str) -> None: if len(errors_dict) > 0: raise star_firmware_string_to_error(error_code_dict=errors_dict, raw_response=resp) + async def _ensure_iswap_parked(self) -> None: + """Park the iSWAP if it is installed and not already parked.""" + if self.iswap is not None and not self.iswap.parked: + await self.iswap.park() + def _parse_response(self, resp: str, fmt: Any) -> dict: return parse_star_fw_string(resp, fmt) @@ -205,9 +211,12 @@ async def define_tip_needle( tip_size: TipSize, pickup_method: TipPickupMethod, ) -> None: - assert 0 <= tip_type_table_index <= 99 - assert 1 <= tip_length <= 1999 - assert 1 <= maximum_tip_volume <= 56000 + if not 0 <= tip_type_table_index <= 99: + raise ValueError("tip_type_table_index must be between 0 and 99") + if not 1 <= tip_length <= 1999: + raise ValueError("tip_length must be between 1 and 1999") + if not 1 <= maximum_tip_volume <= 56000: + raise ValueError("maximum_tip_volume must be between 1 and 56000") await self.send_command( module="C0", @@ -229,29 +238,21 @@ async def setup(self): self.extended_conf = await self._request_extended_configuration() # Create backends based on discovered config. - from .pip_backend import STARPIPBackend # deferred to avoid circular imports - self.pip = STARPIPBackend(self) self._channels_minimum_y_spacing = await self.channels_request_y_minimum_spacing() if self.extended_conf.left_x_drive.core_96_head_installed: - from .head96_backend import STARHead96Backend - self.head96 = STARHead96Backend(self) else: self.head96 = None if self.extended_conf.left_x_drive.iswap_installed: - from .iswap import iSWAPBackend - self.iswap = iSWAPBackend(driver=self) else: self.iswap = None if self.machine_conf.auto_load_installed: - from .autoload import STARAutoload - self.autoload = STARAutoload( driver=self, instrument_size_slots=self.extended_conf.instrument_size_slots, @@ -259,28 +260,41 @@ async def setup(self): else: self.autoload = None - from .x_arm import STARXArm - self.left_x_arm = STARXArm(driver=self, side="left") if self.extended_conf.right_x_drive_large: self.right_x_arm = STARXArm(driver=self, side="right") else: self.right_x_arm = None - # Cover is always present. - from .cover import STARCover - self.cover = STARCover(driver=self) if (self.machine_conf.wash_station_1_installed or self.machine_conf.wash_station_2_installed): - from .wash_station import STARWashStation - self.wash_station = STARWashStation(driver=self) else: self.wash_station = None + # Initialize subsystems. + for sub in self._subsystems: + await sub._on_setup() + + @property + def _subsystems(self): + """All active subsystems, for lifecycle management.""" + subs = [self.cover] + if self.autoload is not None: + subs.append(self.autoload) + if self.left_x_arm is not None: + subs.append(self.left_x_arm) + if self.right_x_arm is not None: + subs.append(self.right_x_arm) + if self.wash_station is not None: + subs.append(self.wash_station) + return subs + async def stop(self): + for sub in reversed(self._subsystems): + await sub._on_stop() await super().stop() self.machine_conf = None self.extended_conf = None @@ -601,7 +615,8 @@ async def store_installation_data( serial_number: 4-character serial number string. """ - assert len(serial_number) == 4, "serial number must be 4 chars long" + if len(serial_number) != 4: + raise ValueError("serial number must be 4 chars long") return await self.send_command(module="C0", command="SI", si=date, sn=serial_number) @@ -619,7 +634,8 @@ async def store_verification_data( verification_status: verification status. """ - assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" + if not 0 <= verification_subject <= 24: + raise ValueError("verification_subject must be between 0 and 24") return await self.send_command( module="C0", @@ -727,8 +743,10 @@ async def set_deck_data(self, data_index: int = 0, data_stream: str = "0"): data_stream: data stream (12 characters). Default . """ - assert 0 <= data_index <= 9, "data_index must be between 0 and 9" - assert len(data_stream) == 12, "data_stream must be 12 chars" + if not 0 <= data_index <= 9: + raise ValueError("data_index must be between 0 and 9") + if len(data_stream) != 12: + raise ValueError("data_stream must be 12 chars") return await self.send_command( module="C0", @@ -766,7 +784,8 @@ async def request_verification_data(self, verification_subject: int = 0): verification_subject: verification subject. Must be between 0 and 24. Default 0. """ - assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" + if not 0 <= verification_subject <= 24: + raise ValueError("verification_subject must be between 0 and 24") # TODO: parse results. return await self.send_command(module="C0", command="RO", vo=verification_subject) @@ -824,17 +843,16 @@ async def occupy_and_provide_area_for_external_access( 1) all arms left. 2) all arms right. """ - assert 0 <= taken_area_identification_number <= 9999, ( - "taken_area_identification_number must be between 0 and 9999" - ) - assert 0 <= taken_area_left_margin <= 99, "taken_area_left_margin must be between 0 and 99" - assert 0 <= taken_area_left_margin_direction <= 1, ( - "taken_area_left_margin_direction must be between 0 and 1" - ) - assert 0 <= taken_area_size <= 50000, "taken_area_size must be between 0 and 50000" - assert 0 <= arm_preposition_mode_related_to_taken_areas <= 2, ( - "arm_preposition_mode_related_to_taken_areas must be between 0 and 2" - ) + if not 0 <= taken_area_identification_number <= 9999: + raise ValueError("taken_area_identification_number must be between 0 and 9999") + if not 0 <= taken_area_left_margin <= 99: + raise ValueError("taken_area_left_margin must be between 0 and 99") + if not 0 <= taken_area_left_margin_direction <= 1: + raise ValueError("taken_area_left_margin_direction must be between 0 and 1") + if not 0 <= taken_area_size <= 50000: + raise ValueError("taken_area_size must be between 0 and 50000") + if not 0 <= arm_preposition_mode_related_to_taken_areas <= 2: + raise ValueError("arm_preposition_mode_related_to_taken_areas must be between 0 and 2") return await self.send_command( module="C0", @@ -854,9 +872,8 @@ async def release_occupied_area(self, taken_area_identification_number: int = 0) Must be between 0 and 99. Default 0. """ - assert 0 <= taken_area_identification_number <= 99, ( - "taken_area_identification_number must be between 0 and 99" - ) + if not 0 <= taken_area_identification_number <= 99: + raise ValueError("taken_area_identification_number must be between 0 and 99") return await self.send_command( module="C0", @@ -876,22 +893,22 @@ async def set_instrument_configuration( configuration_data_3: Optional[str] = None, # TODO: configuration byte instrument_size_in_slots_x_range: int = 54, auto_load_size_in_slots: int = 54, - tip_waste_x_position: int = 13400, + tip_waste_x_position: float = 1340.0, right_x_drive_configuration_byte_1: int = 0, right_x_drive_configuration_byte_2: int = 0, - minimal_iswap_collision_free_position: int = 3500, - maximal_iswap_collision_free_position: int = 11400, - left_x_arm_width: int = 3700, - right_x_arm_width: int = 3700, + minimal_iswap_collision_free_position: float = 350.0, + maximal_iswap_collision_free_position: float = 1140.0, + left_x_arm_width: float = 370.0, + right_x_arm_width: float = 370.0, num_pip_channels: int = 0, num_xl_channels: int = 0, num_robotic_channels: int = 0, - minimal_raster_pitch_of_pip_channels: int = 90, - minimal_raster_pitch_of_xl_channels: int = 360, - minimal_raster_pitch_of_robotic_channels: int = 360, - pip_maximal_y_position: int = 6065, - left_arm_minimal_y_position: int = 60, - right_arm_minimal_y_position: int = 60, + minimal_raster_pitch_of_pip_channels: float = 9.0, + minimal_raster_pitch_of_xl_channels: float = 36.0, + minimal_raster_pitch_of_robotic_channels: float = 36.0, + pip_maximal_y_position: float = 606.5, + left_arm_minimal_y_position: float = 6.0, + right_arm_minimal_y_position: float = 6.0, ): """Set instrument configuration @@ -901,77 +918,73 @@ async def set_instrument_configuration( configuration_data_3: configuration data 3. instrument_size_in_slots_x_range: instrument size in slots (X range). Must be between 10 and 99. Default 54. - auto_load_size_in_slots: auto load size in slots. Must be between 10 + auto_load_size_in_slots: auto load size in slots. Must be between 1 and 54. Default 54. - tip_waste_x_position: tip waste X-position. Must be between 1000 and - 25000. Default 13400. + tip_waste_x_position: tip waste X-position [mm]. Must be between 100 and + 2500. Default 1340. right_x_drive_configuration_byte_1: right X drive configuration byte 1 (see - xl parameter bits). Must be between 0 and 1. Default 0. # TODO: this. + xl parameter bits). Must be between 0 and 1. Default 0. right_x_drive_configuration_byte_2: right X drive configuration byte 2 (see - xn parameter bits). Must be between 0 and 1. Default 0. # TODO: this. - minimal_iswap_collision_free_position: minimal iSWAP collision free position for - direct X access. For explanation of calculation see Fig. 4. Must be between 0 and 30000. - Default 3500. - maximal_iswap_collision_free_position: maximal iSWAP collision free position for - direct X access. For explanation of calculation see Fig. 4. Must be between 0 and 30000. - Default 11400 - left_x_arm_width: width of left X arm [0.1 mm]. Must be between 0 and 9999. Default 3700. - right_x_arm_width: width of right X arm [0.1 mm]. Must be between 0 and 9999. Default 3700. + xn parameter bits). Must be between 0 and 1. Default 0. + minimal_iswap_collision_free_position: minimal iSWAP collision free position [mm]. + Must be between 0 and 3000. Default 350. + maximal_iswap_collision_free_position: maximal iSWAP collision free position [mm]. + Must be between 0 and 3000. Default 1140. + left_x_arm_width: width of left X arm [mm]. Must be between 0 and 999.9. Default 370. + right_x_arm_width: width of right X arm [mm]. Must be between 0 and 999.9. Default 370. num_pip_channels: number of PIP channels. Must be between 0 and 16. Default 0. num_xl_channels: number of XL channels. Must be between 0 and 8. Default 0. num_robotic_channels: number of Robotic channels. Must be between 0 and 8. Default 0. - minimal_raster_pitch_of_pip_channels: minimal raster pitch of PIP channels [0.1 mm]. Must - be between 0 and 999. Default 90. - minimal_raster_pitch_of_xl_channels: minimal raster pitch of XL channels [0.1 mm]. Must be - between 0 and 999. Default 360. - minimal_raster_pitch_of_robotic_channels: minimal raster pitch of Robotic channels [0.1 mm]. - Must be between 0 and 999. Default 360. - pip_maximal_y_position: PIP maximal Y position [0.1 mm]. Must be between 0 and 9999. - Default 6065. - left_arm_minimal_y_position: left arm minimal Y position [0.1 mm]. Must be between 0 and 9999. - Default 60. - right_arm_minimal_y_position: right arm minimal Y position [0.1 mm]. Must be between 0 - and 9999. Default 60. + minimal_raster_pitch_of_pip_channels: minimal raster pitch of PIP channels [mm]. + Must be between 0 and 99.9. Default 9. + minimal_raster_pitch_of_xl_channels: minimal raster pitch of XL channels [mm]. + Must be between 0 and 99.9. Default 36. + minimal_raster_pitch_of_robotic_channels: minimal raster pitch of Robotic channels [mm]. + Must be between 0 and 99.9. Default 36. + pip_maximal_y_position: PIP maximal Y position [mm]. Must be between 0 and 999.9. + Default 606.5. + left_arm_minimal_y_position: left arm minimal Y position [mm]. Must be between 0 and 999.9. + Default 6. + right_arm_minimal_y_position: right arm minimal Y position [mm]. Must be between 0 + and 999.9. Default 6. """ - assert 10 <= instrument_size_in_slots_x_range <= 99, ( - "instrument_size_in_slots_x_range must be between 10 and 99" - ) - assert 1 <= auto_load_size_in_slots <= 54, "auto_load_size_in_slots must be between 1 and 54" - assert 1000 <= tip_waste_x_position <= 25000, "tip_waste_x_position must be between 1 and 25000" - assert 0 <= right_x_drive_configuration_byte_1 <= 1, ( - "right_x_drive_configuration_byte_1 must be between 0 and 1" - ) - assert 0 <= right_x_drive_configuration_byte_2 <= 1, ( - "right_x_drive_configuration_byte_2 must be between 0 and must1" - ) - assert 0 <= minimal_iswap_collision_free_position <= 30000, ( - "minimal_iswap_collision_free_position must be between 0 and 30000" - ) - assert 0 <= maximal_iswap_collision_free_position <= 30000, ( - "maximal_iswap_collision_free_position must be between 0 and 30000" - ) - assert 0 <= left_x_arm_width <= 9999, "left_x_arm_width must be between 0 and 9999" - assert 0 <= right_x_arm_width <= 9999, "right_x_arm_width must be between 0 and 9999" - assert 0 <= num_pip_channels <= 16, "num_pip_channels must be between 0 and 16" - assert 0 <= num_xl_channels <= 8, "num_xl_channels must be between 0 and 8" - assert 0 <= num_robotic_channels <= 8, "num_robotic_channels must be between 0 and 8" - assert 0 <= minimal_raster_pitch_of_pip_channels <= 999, ( - "minimal_raster_pitch_of_pip_channels must be between 0 and 999" - ) - assert 0 <= minimal_raster_pitch_of_xl_channels <= 999, ( - "minimal_raster_pitch_of_xl_channels must be between 0 and 999" - ) - assert 0 <= minimal_raster_pitch_of_robotic_channels <= 999, ( - "minimal_raster_pitch_of_robotic_channels must be between 0 and 999" - ) - assert 0 <= pip_maximal_y_position <= 9999, "pip_maximal_y_position must be between 0 and 9999" - assert 0 <= left_arm_minimal_y_position <= 9999, ( - "left_arm_minimal_y_position must be between 0 and 9999" - ) - assert 0 <= right_arm_minimal_y_position <= 9999, ( - "right_arm_minimal_y_position must be between 0 and 9999" - ) + if not 10 <= instrument_size_in_slots_x_range <= 99: + raise ValueError("instrument_size_in_slots_x_range must be between 10 and 99") + if not 1 <= auto_load_size_in_slots <= 54: + raise ValueError("auto_load_size_in_slots must be between 1 and 54") + if not 100 <= tip_waste_x_position <= 2500: + raise ValueError("tip_waste_x_position must be between 100 and 2500") + if not 0 <= right_x_drive_configuration_byte_1 <= 1: + raise ValueError("right_x_drive_configuration_byte_1 must be between 0 and 1") + if not 0 <= right_x_drive_configuration_byte_2 <= 1: + raise ValueError("right_x_drive_configuration_byte_2 must be between 0 and 1") + if not 0 <= minimal_iswap_collision_free_position <= 3000: + raise ValueError("minimal_iswap_collision_free_position must be between 0 and 3000") + if not 0 <= maximal_iswap_collision_free_position <= 3000: + raise ValueError("maximal_iswap_collision_free_position must be between 0 and 3000") + if not 0 <= left_x_arm_width <= 999.9: + raise ValueError("left_x_arm_width must be between 0 and 999.9") + if not 0 <= right_x_arm_width <= 999.9: + raise ValueError("right_x_arm_width must be between 0 and 999.9") + if not 0 <= num_pip_channels <= 16: + raise ValueError("num_pip_channels must be between 0 and 16") + if not 0 <= num_xl_channels <= 8: + raise ValueError("num_xl_channels must be between 0 and 8") + if not 0 <= num_robotic_channels <= 8: + raise ValueError("num_robotic_channels must be between 0 and 8") + if not 0 <= minimal_raster_pitch_of_pip_channels <= 99.9: + raise ValueError("minimal_raster_pitch_of_pip_channels must be between 0 and 99.9") + if not 0 <= minimal_raster_pitch_of_xl_channels <= 99.9: + raise ValueError("minimal_raster_pitch_of_xl_channels must be between 0 and 99.9") + if not 0 <= minimal_raster_pitch_of_robotic_channels <= 99.9: + raise ValueError("minimal_raster_pitch_of_robotic_channels must be between 0 and 99.9") + if not 0 <= pip_maximal_y_position <= 999.9: + raise ValueError("pip_maximal_y_position must be between 0 and 999.9") + if not 0 <= left_arm_minimal_y_position <= 999.9: + raise ValueError("left_arm_minimal_y_position must be between 0 and 999.9") + if not 0 <= right_arm_minimal_y_position <= 999.9: + raise ValueError("right_arm_minimal_y_position must be between 0 and 999.9") return await self.send_command( module="C0", @@ -981,22 +994,22 @@ async def set_instrument_configuration( ke=configuration_data_3, xt=instrument_size_in_slots_x_range, xa=auto_load_size_in_slots, - xw=tip_waste_x_position, + xw=round(tip_waste_x_position * 10), xr=right_x_drive_configuration_byte_1, xo=right_x_drive_configuration_byte_2, - xm=minimal_iswap_collision_free_position, - xx=maximal_iswap_collision_free_position, - xu=left_x_arm_width, - xv=right_x_arm_width, + xm=round(minimal_iswap_collision_free_position * 10), + xx=round(maximal_iswap_collision_free_position * 10), + xu=round(left_x_arm_width * 10), + xv=round(right_x_arm_width * 10), kp=num_pip_channels, kc=num_xl_channels, kr=num_robotic_channels, - ys=minimal_raster_pitch_of_pip_channels, - kl=minimal_raster_pitch_of_xl_channels, - km=minimal_raster_pitch_of_robotic_channels, - ym=pip_maximal_y_position, - yu=left_arm_minimal_y_position, - yx=right_arm_minimal_y_position, + ys=round(minimal_raster_pitch_of_pip_channels * 10), + kl=round(minimal_raster_pitch_of_xl_channels * 10), + km=round(minimal_raster_pitch_of_robotic_channels * 10), + ym=round(pip_maximal_y_position * 10), + yu=round(left_arm_minimal_y_position * 10), + yx=round(right_arm_minimal_y_position * 10), ) async def pre_initialize_instrument(self): diff --git a/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py b/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py index 5448059b0e7..6d481b2f38a 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/star/head96_backend.py @@ -41,7 +41,8 @@ def _dispensing_mode_for_op(empty: bool, jet: bool, blow_out: bool) -> int: def _channel_pattern_to_hex(pattern: List[bool]) -> str: """Convert a list of 96 booleans to the hex string expected by firmware.""" - assert len(pattern) == 96, "channel_pattern must be a list of 96 boolean values" + if len(pattern) != 96: + raise ValueError("channel_pattern must be a list of 96 boolean values") channel_pattern_bin_str = reversed(["1" if x else "0" for x in pattern]) return hex(int("".join(channel_pattern_bin_str), 2)).upper()[2:] @@ -53,7 +54,7 @@ class STARHead96Backend(Head96Backend): _traversal_height: float = 245.0 def __init__(self, driver: STARDriver): - self._driver = driver + self.driver = driver # --------------------------------------------------------------------------- # Pick up tips @@ -88,7 +89,7 @@ async def pick_up_tips96( if not isinstance(prototypical_tip, HamiltonTip): raise TypeError("Tip type must be HamiltonTip.") - ttti = await self._driver.request_or_assign_tip_type_index(prototypical_tip) + ttti = await self.driver.request_or_assign_tip_type_index(prototypical_tip) tip_length = prototypical_tip.total_tip_length fitting_depth = prototypical_tip.fitting_depth @@ -120,7 +121,7 @@ async def pick_up_tips96( # Pre-computed increment values (uL / 0.019340933): # position=218.19uL -> 11281, speed=261.1uL/s -> 13500, # stop_speed=0 -> 0, acceleration=17406.84uL/s^2 -> 900000 - await self._driver.send_command( + await self.driver.send_command( module="H0", command="DQ", dq="11281", @@ -130,7 +131,7 @@ async def pick_up_tips96( dw="15", ) - await self._driver.send_command( + await self.driver.send_command( module="C0", command="EP", xs=f"{abs(round(pickup_position.x * 10)):05}", @@ -171,7 +172,8 @@ async def drop_tips96( tip_spot_a1 = drop.resource.get_item(backend_params.alignment_tipspot_identifier) position = tip_spot_a1.get_absolute_location() + tip_spot_a1.center() + drop.offset tip_rack = tip_spot_a1.parent - assert tip_rack is not None + if tip_rack is None: + raise ValueError("Tip spot parent (tip rack) must not be None") position.z = tip_rack.get_absolute_location().z + 1.45 else: # Drop into trash or other resource: center the head in the resource. @@ -179,7 +181,7 @@ async def drop_tips96( traversal = self._traversal_height - await self._driver.send_command( + await self.driver.send_command( module="C0", command="ER", xs=f"{abs(round(position.x * 10)):05}", @@ -235,7 +237,8 @@ async def aspirate96( # Compute position if isinstance(aspiration, MultiHeadAspirationPlate): plate = aspiration.wells[0].parent - assert plate is not None, "MultiHeadAspirationPlate well parent must not be None" + if plate is None: + raise ValueError("MultiHeadAspirationPlate well parent must not be None") rot = plate.get_absolute_rotation() if rot.x % 360 != 0 or rot.y % 360 != 0: raise ValueError("Plate rotation around x or y is not supported for 96 head operations") @@ -277,7 +280,7 @@ async def aspirate96( immersion_depth = backend_params.immersion_depth immersion_depth_direction = 0 if immersion_depth >= 0 else 1 - await self._driver.send_command( + await self.driver.send_command( module="C0", command="EA", aa=backend_params.aspiration_type, @@ -364,7 +367,8 @@ async def dispense96( # Compute position if isinstance(dispense, MultiHeadDispensePlate): plate = dispense.wells[0].parent - assert plate is not None, "MultiHeadDispensePlate well parent must not be None" + if plate is None: + raise ValueError("MultiHeadDispensePlate well parent must not be None") rot = plate.get_absolute_rotation() if rot.x % 360 != 0 or rot.y % 360 != 0: raise ValueError("Plate rotation around x or y is not supported for 96 head operations") @@ -412,7 +416,7 @@ async def dispense96( immersion_depth = backend_params.immersion_depth immersion_depth_direction = 0 if immersion_depth >= 0 else 1 - await self._driver.send_command( + await self.driver.send_command( module="C0", command="ED", da=dispense_mode, diff --git a/pylabrobot/hamilton/liquid_handlers/star/iswap.py b/pylabrobot/hamilton/liquid_handlers/star/iswap.py index 704df06df6c..5d84b5b934f 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/iswap.py +++ b/pylabrobot/hamilton/liquid_handlers/star/iswap.py @@ -1,15 +1,19 @@ +from __future__ import annotations + import enum from contextlib import asynccontextmanager from dataclasses import dataclass -from typing import Literal, Optional, cast +from typing import TYPE_CHECKING, Literal, Optional, cast from pylabrobot.arms.backend import OrientableGripperArmBackend from pylabrobot.arms.standard import GripDirection, GripperLocation from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver from pylabrobot.resources import Coordinate from pylabrobot.resources.rotation import Rotation +if TYPE_CHECKING: + from pylabrobot.hamilton.liquid_handlers.star.driver import STARDriver + def _direction_degrees_to_grip_direction(degrees: float) -> int: """Convert rotation angle in degrees to firmware grip_direction (1-4). @@ -195,21 +199,21 @@ async def rotation_drive_request_y(self) -> float: raise RuntimeError("iSWAP is not installed") resp = await self.driver.send_command(module="R0", command="RY", fmt="ry##### (n)") iswap_y_pos = resp["ry"][1] # 0 = FW counter, 1 = HW counter - return round(STARDriver.y_drive_increment_to_mm(iswap_y_pos), 1) + return round(self.driver.y_drive_increment_to_mm(iswap_y_pos), 1) async def move_x(self, x_position: float) -> None: """Move iSWAP X to an absolute position [mm].""" - loc = (await self.get_gripper_location()).location + loc = (await self.request_gripper_location()).location await self.move_x_relative(step_size=x_position - loc.x, allow_splitting=True) async def move_y(self, y_position: float) -> None: """Move iSWAP Y to an absolute position [mm].""" - loc = (await self.get_gripper_location()).location + loc = (await self.request_gripper_location()).location await self.move_y_relative(step_size=y_position - loc.y, allow_splitting=True) async def move_z(self, z_position: float) -> None: """Move iSWAP Z to an absolute position [mm].""" - loc = (await self.get_gripper_location()).location + loc = (await self.request_gripper_location()).location await self.move_z_relative(step_size=z_position - loc.z, allow_splitting=True) # -- rotation / wrist drive ------------------------------------------------ @@ -288,10 +292,14 @@ async def rotate( Velocity units are incr/sec. Acceleration units are 1000 incr/sec^2. """ - assert 20 <= gripper_velocity <= 75_000 - assert 5 <= gripper_acceleration <= 200 - assert 20 <= wrist_velocity <= 65_000 - assert 20 <= wrist_acceleration <= 200 + if not 20 <= gripper_velocity <= 75_000: + raise ValueError("gripper_velocity must be between 20 and 75000") + if not 5 <= gripper_acceleration <= 200: + raise ValueError("gripper_acceleration must be between 5 and 200") + if not 20 <= wrist_velocity <= 65_000: + raise ValueError("wrist_velocity must be between 20 and 65000") + if not 20 <= wrist_acceleration <= 200: + raise ValueError("wrist_acceleration must be between 20 and 200") RDO = iSWAPBackend.RotationDriveOrientation position = 0 @@ -370,93 +378,115 @@ async def collapse_gripper_arm( async def prepare_teaching( self, - x_position: int = 0, + x_position: float = 0, x_direction: int = 0, - y_position: int = 0, + y_position: float = 0, y_direction: int = 0, - z_position: int = 0, + z_position: float = 0, z_direction: int = 0, location: int = 0, - hotel_depth: int = 1300, + hotel_depth: float = 130.0, grip_direction: int = 1, - minimum_traverse_height: int = 3600, + minimum_traverse_height: float = 360.0, collision_control_level: int = 1, acceleration_index_high_acc: int = 4, acceleration_index_low_acc: int = 1, ) -> None: """Prepare for teaching with iSWAP (C0 PT). - All position args are in 0.1mm firmware units. + All position args are in mm. """ - assert 0 <= x_position <= 30000 - assert 0 <= x_direction <= 1 - assert 0 <= y_position <= 6500 - assert 0 <= y_direction <= 1 - assert 0 <= z_position <= 3600 - assert 0 <= z_direction <= 1 - assert 0 <= location <= 1 - assert 0 <= hotel_depth <= 3000 - assert 0 <= minimum_traverse_height <= 3600 - assert 0 <= collision_control_level <= 1 - assert 0 <= acceleration_index_high_acc <= 4 - assert 0 <= acceleration_index_low_acc <= 4 + if not 0 <= x_position <= 3000: + raise ValueError("x_position must be between 0 and 3000") + if not 0 <= x_direction <= 1: + raise ValueError("x_direction must be between 0 and 1") + if not 0 <= y_position <= 650: + raise ValueError("y_position must be between 0 and 650") + if not 0 <= y_direction <= 1: + raise ValueError("y_direction must be between 0 and 1") + if not 0 <= z_position <= 360: + raise ValueError("z_position must be between 0 and 360") + if not 0 <= z_direction <= 1: + raise ValueError("z_direction must be between 0 and 1") + if not 0 <= location <= 1: + raise ValueError("location must be between 0 and 1") + if not 0 <= hotel_depth <= 300: + raise ValueError("hotel_depth must be between 0 and 300") + if not 0 <= minimum_traverse_height <= 360: + raise ValueError("minimum_traverse_height must be between 0 and 360") + if not 0 <= collision_control_level <= 1: + raise ValueError("collision_control_level must be between 0 and 1") + if not 0 <= acceleration_index_high_acc <= 4: + raise ValueError("acceleration_index_high_acc must be between 0 and 4") + if not 0 <= acceleration_index_low_acc <= 4: + raise ValueError("acceleration_index_low_acc must be between 0 and 4") await self.driver.send_command( module="C0", command="PT", - xs=f"{x_position:05}", + xs=f"{round(x_position * 10):05}", xd=x_direction, - yj=f"{y_position:04}", + yj=f"{round(y_position * 10):04}", yd=y_direction, - zj=f"{z_position:04}", + zj=f"{round(z_position * 10):04}", zd=z_direction, hh=location, - hd=f"{hotel_depth:04}", + hd=f"{round(hotel_depth * 10):04}", gr=grip_direction, - th=f"{minimum_traverse_height:04}", + th=f"{round(minimum_traverse_height * 10):04}", ga=collision_control_level, xe=f"{acceleration_index_high_acc} {acceleration_index_low_acc}", ) async def get_logic_position( self, - x_position: int = 0, + x_position: float = 0, x_direction: int = 0, - y_position: int = 0, + y_position: float = 0, y_direction: int = 0, - z_position: int = 0, + z_position: float = 0, z_direction: int = 0, location: int = 0, - hotel_depth: int = 1300, + hotel_depth: float = 130.0, grip_direction: int = 1, collision_control_level: int = 1, ) -> None: """Get logic iSWAP position (C0 PC). - All position args are in 0.1mm firmware units. + All position args are in mm. """ - assert 0 <= x_position <= 30000 - assert 0 <= x_direction <= 1 - assert 0 <= y_position <= 6500 - assert 0 <= y_direction <= 1 - assert 0 <= z_position <= 3600 - assert 0 <= z_direction <= 1 - assert 0 <= location <= 1 - assert 0 <= hotel_depth <= 3000 - assert 1 <= grip_direction <= 4 - assert 0 <= collision_control_level <= 1 + if not 0 <= x_position <= 3000: + raise ValueError("x_position must be between 0 and 3000") + if not 0 <= x_direction <= 1: + raise ValueError("x_direction must be between 0 and 1") + if not 0 <= y_position <= 650: + raise ValueError("y_position must be between 0 and 650") + if not 0 <= y_direction <= 1: + raise ValueError("y_direction must be between 0 and 1") + if not 0 <= z_position <= 360: + raise ValueError("z_position must be between 0 and 360") + if not 0 <= z_direction <= 1: + raise ValueError("z_direction must be between 0 and 1") + if not 0 <= location <= 1: + raise ValueError("location must be between 0 and 1") + if not 0 <= hotel_depth <= 300: + raise ValueError("hotel_depth must be between 0 and 300") + if not 1 <= grip_direction <= 4: + raise ValueError("grip_direction must be between 1 and 4") + if not 0 <= collision_control_level <= 1: + raise ValueError("collision_control_level must be between 0 and 1") await self.driver.send_command( module="C0", command="PC", - xs=x_position, + xs=round(x_position * 10), xd=x_direction, - yj=y_position, + yj=round(y_position * 10), yd=y_direction, - zj=z_position, + zj=round(z_position * 10), zd=z_direction, hh=location, - hd=hotel_depth, + hd=round(hotel_depth * 10), gr=grip_direction, ga=collision_control_level, ) @@ -479,8 +509,10 @@ async def slow(self, wrist_velocity: int = 20_000, gripper_velocity: int = 20_00 wrist_velocity: Wrist velocity in incr/sec (20..65000). gripper_velocity: Gripper velocity in incr/sec (20..75000). """ - assert 20 <= gripper_velocity <= 75_000 - assert 20 <= wrist_velocity <= 65_000 + if not 20 <= gripper_velocity <= 75_000: + raise ValueError("gripper_velocity must be between 20 and 75000") + if not 20 <= wrist_velocity <= 65_000: + raise ValueError("wrist_velocity must be between 20 and 65000") original_wv = await self._get_r0_parameter("wv", "wv#####") original_tv = await self._get_r0_parameter("tv", "tv#####") diff --git a/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md b/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md index 6a694985174..2c2d2c84f83 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md +++ b/pylabrobot/hamilton/liquid_handlers/star/misc/architecture.md @@ -227,7 +227,7 @@ await star.setup() │ ├─ Wire capability frontends to backends (on STAR device) │ self.pip = PIP(backend=driver.pip) - │ self.head96 = Head96Capability(backend=driver.head96) # if installed + │ self.head96 = Head96(backend=driver.head96) # if installed │ self.iswap = OrientableArm(backend=driver.iswap) # if installed │ └─ Call _on_setup() for each Capability diff --git a/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py index 5b3fc9c711a..9ab89cc6d1e 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py +++ b/pylabrobot/hamilton/liquid_handlers/star/pip_backend.py @@ -48,7 +48,8 @@ def _ops_to_fw_positions( need a ``deck`` reference. This mirrors HamiltonLiquidHandler._ops_to_fw_positions but is self-contained. """ - assert use_channels == sorted(use_channels), "Channels must be sorted." + if use_channels != sorted(use_channels): + raise ValueError("Channels must be sorted.") x_positions: List[int] = [] y_positions: List[int] = [] @@ -187,16 +188,11 @@ class STARPIPBackend(PIPBackend): """Translates PIP operations into STAR firmware commands via the driver.""" def __init__(self, driver: STARDriver): - self._driver = driver + self.driver = driver @property def num_channels(self) -> int: - return self._driver.num_channels - - async def _ensure_iswap_parked(self) -> None: - """Park the iSWAP if it is installed and not already parked.""" - if self._driver.iswap is not None and not self._driver.iswap.parked: - await self._driver.iswap.park() + return self.driver.num_channels def _ensure_can_reach_position( self, @@ -205,10 +201,10 @@ def _ensure_can_reach_position( op_name: str, ) -> None: """Validate that each channel can physically reach its target Y position.""" - if self._driver.extended_conf is None: + if self.driver.extended_conf is None: return # skip validation if config not available (e.g. chatterbox) - ext = self._driver.extended_conf - spacings = self._driver._channels_minimum_y_spacing + ext = self.driver.extended_conf + spacings = self.driver._channels_minimum_y_spacing if not spacings: spacings = [ext.min_raster_pitch_pip_channels] * self.num_channels @@ -245,7 +241,7 @@ async def pick_up_tips( if not isinstance(backend_params, STARPIPBackend.PickUpTipsParams): backend_params = STARPIPBackend.PickUpTipsParams() - await self._ensure_iswap_parked() + await self.driver._ensure_iswap_parked() self._ensure_can_reach_position(use_channels, ops, "pick_up_tips") x_positions, y_positions, channels_involved = _ops_to_fw_positions( @@ -262,8 +258,9 @@ async def pick_up_tips( if len(tips) > 1: raise ValueError("Cannot mix tips with different tip types.") ham_tip = tips.pop() - assert isinstance(ham_tip, HamiltonTip) - ttti = await self._driver.request_or_assign_tip_type_index(ham_tip) + if not isinstance(ham_tip, HamiltonTip): + raise TypeError(f"Expected HamiltonTip, got {type(ham_tip).__name__}") + ttti = await self.driver.request_or_assign_tip_type_index(ham_tip) # Z computations (absolute coordinates). max_z = max( @@ -298,16 +295,19 @@ async def pick_up_tips( # Range validation (matches legacy pick_up_tip assertions). _assert_range(x_positions, 0, 25000, "x_positions") _assert_range(y_positions, 0, 6500, "y_positions") - assert 0 <= begin_tip_pick_up_process <= 3600, "begin_tip_pick_up_process must be 0-3600" - assert 0 <= end_tip_pick_up_process <= 3600, "end_tip_pick_up_process must be 0-3600" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 + if not 0 <= begin_tip_pick_up_process <= 3600: + raise ValueError("begin_tip_pick_up_process must be 0-3600") + if not 0 <= end_tip_pick_up_process <= 3600: + raise ValueError("end_tip_pick_up_process must be 0-3600") + if not 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600: + raise ValueError("minimum_traverse_height_at_beginning_of_a_command must be 0-3600") try: - await self._driver.send_command( + await self.driver.send_command( module="C0", command="TP", tip_pattern=channels_involved, - read_timeout=max(120, self._driver.read_timeout), + read_timeout=max(120, self.driver.read_timeout), xp=[f"{x:05}" for x in x_positions], yp=[f"{y:04}" for y in y_positions], tm=channels_involved, @@ -342,7 +342,7 @@ async def drop_tips( if not isinstance(backend_params, STARPIPBackend.DropTipsParams): backend_params = STARPIPBackend.DropTipsParams() - await self._ensure_iswap_parked() + await self.driver._ensure_iswap_parked() self._ensure_can_reach_position(use_channels, ops, "drop_tips") drop_method = backend_params.drop_method @@ -387,17 +387,21 @@ async def drop_tips( # Range validation (matches legacy discard_tip assertions). _assert_range(x_positions, 0, 25000, "x_positions") _assert_range(y_positions, 0, 6500, "y_positions") - assert 0 <= begin_tip_deposit_process <= 3600, "begin_tip_deposit_process must be 0-3600" - assert 0 <= end_tip_deposit_process <= 3600, "end_tip_deposit_process must be 0-3600" - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 - assert 0 <= z_position_at_end_of_a_command <= 3600 + if not 0 <= begin_tip_deposit_process <= 3600: + raise ValueError("begin_tip_deposit_process must be 0-3600") + if not 0 <= end_tip_deposit_process <= 3600: + raise ValueError("end_tip_deposit_process must be 0-3600") + if not 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600: + raise ValueError("minimum_traverse_height_at_beginning_of_a_command must be 0-3600") + if not 0 <= z_position_at_end_of_a_command <= 3600: + raise ValueError("z_position_at_end_of_a_command must be 0-3600") try: - await self._driver.send_command( + await self.driver.send_command( module="C0", command="TR", tip_pattern=channels_involved, - read_timeout=max(120, self._driver.read_timeout), + read_timeout=max(120, self.driver.read_timeout), xp=[f"{x:05}" for x in x_positions], yp=[f"{y:04}" for y in y_positions], tm=channels_involved, @@ -466,7 +470,7 @@ async def aspirate( if not isinstance(backend_params, STARPIPBackend.AspirateParams): backend_params = STARPIPBackend.AspirateParams() - await self._ensure_iswap_parked() + await self.driver._ensure_iswap_parked() self._ensure_can_reach_position(use_channels, ops, "aspirate") x_positions, y_positions, channels_involved = _ops_to_fw_positions( @@ -573,7 +577,7 @@ async def aspirate( if backend_params.probe_liquid_height: if any(op.liquid_height is not None for op in ops): raise ValueError("Cannot use probe_liquid_height when liquid heights are set.") - liquid_heights = await self._driver.probe_liquid_heights( + liquid_heights = await self.driver.probe_liquid_heights( containers=[op.resource for op in ops], use_channels=use_channels, resource_offsets=[op.offset for op in ops], @@ -634,8 +638,10 @@ async def aspirate( _assert_range(aspiration_types, 0, 2, "aspiration_type") _assert_range(x_positions, 0, 25000, "x_positions") _assert_range(y_positions, 0, 6500, "y_positions") - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 - assert 0 <= min_z_endpos <= 3600 + if not 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600: + raise ValueError("minimum_traverse_height_at_beginning_of_a_command must be 0-3600") + if not 0 <= min_z_endpos <= 3600: + raise ValueError("min_z_endpos must be 0-3600") _assert_range([round(v * 10) for v in lld_search_height], 0, 3600, "lld_search_height") _assert_range([round(v * 10) for v in clot_detection_height], 0, 500, "clot_detection_height") _assert_range([round(v * 10) for v in liquid_surfaces_no_lld], 0, 3600, "liquid_surface_no_lld") @@ -664,7 +670,8 @@ async def aspirate( _assert_range([round(v * 10) for v in mix_speed], 4, 5000, "mix_speed") _assert_range([round(v * 10) for v in mix_surface_following_distance], 0, 3600, "mix_surface_following_distance") _assert_range(limit_curve_index, 0, 999, "limit_curve_index") - assert 0 <= backend_params.recording_mode <= 2, "recording_mode must be between 0 and 2" + if not 0 <= backend_params.recording_mode <= 2: + raise ValueError("recording_mode must be between 0 and 2") # 2nd section aspiration range checks _assert_range([round(v * 10) for v in _fill( backend_params.retract_height_over_2nd_section_to_empty_tip, [0.0] * n)], 0, 3600, @@ -682,11 +689,11 @@ async def aspirate( backend_params.cup_upper_edge, [0.0] * n)], 0, 3600, "cup_upper_edge") try: - await self._driver.send_command( + await self.driver.send_command( module="C0", command="AS", tip_pattern=channels_involved, - read_timeout=max(300, self._driver.read_timeout), + read_timeout=max(300, self.driver.read_timeout), at=[f"{at:01}" for at in aspiration_types], tm=channels_involved, xp=[f"{xp:05}" for xp in x_positions], @@ -787,7 +794,7 @@ async def dispense( if not isinstance(backend_params, STARPIPBackend.DispenseParams): backend_params = STARPIPBackend.DispenseParams() - await self._ensure_iswap_parked() + await self.driver._ensure_iswap_parked() self._ensure_can_reach_position(use_channels, ops, "dispense") x_positions, y_positions, channels_involved = _ops_to_fw_positions( @@ -899,7 +906,7 @@ async def dispense( if backend_params.probe_liquid_height: if any(op.liquid_height is not None for op in ops): raise ValueError("Cannot use probe_liquid_height when liquid heights are set.") - liquid_heights = await self._driver.probe_liquid_heights( + liquid_heights = await self.driver.probe_liquid_heights( containers=[op.resource for op in ops], use_channels=use_channels, resource_offsets=[op.offset for op in ops], @@ -956,8 +963,10 @@ async def dispense( _assert_range([round(v * 10) for v in surface_following_distance], 0, 3600, "surface_following_distance") _assert_range([round(v * 10) for v in second_section_height], 0, 3600, "second_section_height") _assert_range([round(v * 10) for v in second_section_ratio], 0, 10000, "second_section_ratio") - assert 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600 - assert 0 <= min_z_endpos <= 3600 + if not 0 <= minimum_traverse_height_at_beginning_of_a_command <= 3600: + raise ValueError("minimum_traverse_height_at_beginning_of_a_command must be 0-3600") + if not 0 <= min_z_endpos <= 3600: + raise ValueError("min_z_endpos must be 0-3600") _assert_range([round(v * 10) for v in volumes], 0, 12500, "dispense_volumes") _assert_range([round(v * 10) for v in flow_rates], 4, 5000, "dispense_speed") _assert_range([round(v * 10) for v in cut_off_speed], 4, 5000, "cut_off_speed") @@ -965,7 +974,8 @@ async def dispense( _assert_range([round(v * 10) for v in transport_air_volume], 0, 500, "transport_air_volume") _assert_range([round(v * 10) for v in blow_out_air_volumes], 0, 9999, "blow_out_air_volume") _assert_range([m.value for m in lld_mode], 0, 4, "lld_mode") - assert 0 <= side_touch_off_distance <= 45, "side_touch_off_distance must be between 0 and 45" + if not 0 <= side_touch_off_distance <= 45: + raise ValueError("side_touch_off_distance must be between 0 and 45") _assert_range([round(v * 10) for v in dispense_position_above_z_touch_off], 0, 100, "dispense_position_above_z_touch_off") _assert_range(gamma_lld_sensitivity, 1, 4, "gamma_lld_sensitivity") _assert_range(dp_lld_sensitivity, 1, 4, "dp_lld_sensitivity") @@ -977,14 +987,15 @@ async def dispense( _assert_range([round(v * 10) for v in mix_speed], 4, 5000, "mix_speed") _assert_range([round(v * 10) for v in mix_surface_following_distance], 0, 3600, "mix_surface_following_distance") _assert_range(limit_curve_index, 0, 999, "limit_curve_index") - assert 0 <= backend_params.recording_mode <= 2, "recording_mode must be between 0 and 2" + if not 0 <= backend_params.recording_mode <= 2: + raise ValueError("recording_mode must be between 0 and 2") try: - await self._driver.send_command( + await self.driver.send_command( module="C0", command="DS", tip_pattern=channels_involved, - read_timeout=max(300, self._driver.read_timeout), + read_timeout=max(300, self.driver.read_timeout), dm=[f"{dm:01}" for dm in dispensing_modes], tm=[f"{int(t):01}" for t in channels_involved], xp=[f"{xp:05}" for xp in x_positions], @@ -1040,11 +1051,11 @@ def can_pick_up_tip(self, channel_idx: int, tip: Tip) -> bool: async def spread_pip_channels(self): """Spread PIP channels (C0:JE).""" - return await self._driver.send_command(module="C0", command="JE") + return await self.driver.send_command(module="C0", command="JE") async def move_all_channels_in_z_safety(self): """Move all pipetting channels to Z-safety position (C0:ZA).""" - return await self._driver.send_command(module="C0", command="ZA") + return await self.driver.send_command(module="C0", command="ZA") async def position_max_free_y_for_n(self, pipetting_channel_index: int): """Position all pipetting channels so that there is maximum free Y range for channel n (C0:JP). @@ -1052,16 +1063,15 @@ async def position_max_free_y_for_n(self, pipetting_channel_index: int): Args: pipetting_channel_index: Index of pipetting channel. Must be between 0 and num_channels - 1. """ - if self._driver.iswap is not None and not self._driver.iswap.parked: - await self._driver.iswap.park() + if self.driver.iswap is not None and not self.driver.iswap.parked: + await self.driver.iswap.park() - assert 0 <= pipetting_channel_index < self.num_channels, ( - "pipetting_channel_index must be between 0 and num_channels - 1" - ) + if not 0 <= pipetting_channel_index < self.num_channels: + raise ValueError("pipetting_channel_index must be between 0 and num_channels - 1") # convert Python's 0-based indexing to Hamilton firmware's 1-based indexing pipetting_channel_index_fw = pipetting_channel_index + 1 - return await self._driver.send_command( + return await self.driver.send_command( module="C0", command="JP", pn=f"{pipetting_channel_index_fw:02}", @@ -1088,17 +1098,19 @@ async def move_all_pipetting_channels_to_defined_position( pattern parameter 'tm'). Must be between 0 and 360. Default 0. """ - if self._driver.iswap is not None and not self._driver.iswap.parked: - await self._driver.iswap.park() + if self.driver.iswap is not None and not self.driver.iswap.parked: + await self.driver.iswap.park() - assert 0 <= x_positions <= 2500, "x_positions must be between 0 and 2500" - assert 0 <= y_positions <= 650, "y_positions must be between 0 and 650" - assert 0 <= minimum_traverse_height_at_beginning_of_command <= 360, ( - "minimum_traverse_height_at_beginning_of_command must be between 0 and 360" - ) - assert 0 <= z_endpos <= 360, "z_endpos must be between 0 and 360" + if not 0 <= x_positions <= 2500: + raise ValueError("x_positions must be between 0 and 2500") + if not 0 <= y_positions <= 650: + raise ValueError("y_positions must be between 0 and 650") + if not 0 <= minimum_traverse_height_at_beginning_of_command <= 360: + raise ValueError("minimum_traverse_height_at_beginning_of_command must be between 0 and 360") + if not 0 <= z_endpos <= 360: + raise ValueError("z_endpos must be between 0 and 360") - return await self._driver.send_command( + return await self.driver.send_command( module="C0", command="JM", tm=tip_pattern, @@ -1110,7 +1122,7 @@ async def move_all_pipetting_channels_to_defined_position( async def get_channels_y_positions(self) -> Dict[int, float]: """Get the Y position of all channels in mm (C0:RY).""" - resp = await self._driver.send_command( + resp = await self.driver.send_command( module="C0", command="RY", fmt="ry#### (n)", @@ -1122,8 +1134,8 @@ async def get_channels_y_positions(self) -> Dict[int, float]: # position_channels_in_y_direction, it will raise an error.) The minimum y is 6mm, # so we fix that first (in case that value is misreported). Then, we traverse the # list in reverse and enforce pairwise minimum spacing. - if self._driver.extended_conf is not None: - min_y = self._driver.extended_conf.left_arm_min_y_position + if self.driver.extended_conf is not None: + min_y = self.driver.extended_conf.left_arm_min_y_position else: min_y = 6.0 @@ -1138,7 +1150,7 @@ async def get_channels_y_positions(self) -> Dict[int, float]: y_positions[-1] = min_y for i in range(len(y_positions) - 2, -1, -1): - spacing = self._driver._min_spacing_between(i, i + 1) + spacing = self.driver._min_spacing_between(i, i + 1) if y_positions[i] - y_positions[i + 1] < spacing: y_positions[i] = y_positions[i + 1] + spacing @@ -1159,8 +1171,8 @@ async def position_channels_in_y_direction( if you want to avoid inadvertently moving other channels. """ - if self._driver.iswap is not None and not self._driver.iswap.parked: - await self._driver.iswap.park() + if self.driver.iswap is not None and not self.driver.iswap.parked: + await self.driver.iswap.park() # check that the locations of channels after the move will respect pairwise minimum # spacing and be in descending order @@ -1179,20 +1191,20 @@ async def position_channels_in_y_direction( if intermediate_ch not in ys: channel_locations[intermediate_ch] = channel_locations[ intermediate_ch - 1 - ] - self._driver._min_spacing_between(intermediate_ch - 1, intermediate_ch) + ] - self.driver._min_spacing_between(intermediate_ch - 1, intermediate_ch) # For the channels to the back of `back_channel`, make sure the space between them is # >=min_spacing. We start with the channel closest to `back_channel`, and make sure the # channel behind it is at least min_spacing away, updating if needed. for channel_idx in range(back_channel, 0, -1): - spacing = self._driver._min_spacing_between(channel_idx - 1, channel_idx) + spacing = self.driver._min_spacing_between(channel_idx - 1, channel_idx) if (channel_locations[channel_idx - 1] - channel_locations[channel_idx]) < spacing: channel_locations[channel_idx - 1] = channel_locations[channel_idx] + spacing # Similarly for the channels to the front of `front_channel`, make sure they are all # spaced >= min_spacing apart. - for channel_idx in range(front_channel, self._driver.num_channels - 1): - spacing = self._driver._min_spacing_between(channel_idx, channel_idx + 1) + for channel_idx in range(front_channel, self.driver.num_channels - 1): + spacing = self.driver._min_spacing_between(channel_idx, channel_idx + 1) if (channel_locations[channel_idx] - channel_locations[channel_idx + 1]) < spacing: channel_locations[channel_idx + 1] = channel_locations[channel_idx] - spacing @@ -1200,11 +1212,11 @@ async def position_channels_in_y_direction( if channel_locations[0] > 650: raise ValueError("Channel 0 would hit the back of the robot") - if channel_locations[self._driver.num_channels - 1] < 6: + if channel_locations[self.driver.num_channels - 1] < 6: raise ValueError("Channel N would hit the front of the robot") for i in range(len(channel_locations) - 1): - required = self._driver._min_spacing_between(i, i + 1) + required = self.driver._min_spacing_between(i, i + 1) actual = channel_locations[i] - channel_locations[i + 1] if round(actual * 1000) < round(required * 1000): # compare in um to avoid float issues raise ValueError( @@ -1213,7 +1225,7 @@ async def position_channels_in_y_direction( ) yp = " ".join([f"{round(y * 10):04}" for y in channel_locations.values()]) - return await self._driver.send_command( + return await self.driver.send_command( module="C0", command="JY", yp=yp, @@ -1221,7 +1233,7 @@ async def position_channels_in_y_direction( async def get_channels_z_positions(self) -> Dict[int, float]: """Get the Z position of all channels in mm (C0:RZ).""" - resp = await self._driver.send_command( + resp = await self.driver.send_command( module="C0", command="RZ", fmt="rz#### (n)", @@ -1239,7 +1251,7 @@ async def position_channels_in_z_direction(self, zs: Dict[int, float]): for channel_idx, z in zs.items(): channel_locations[channel_idx] = z - return await self._driver.send_command( + return await self.driver.send_command( module="C0", command="JZ", zp=[f"{round(z * 10):04}" for z in channel_locations.values()], @@ -1254,8 +1266,8 @@ async def initialize_pip(self): y_positions = [round((4050 - i * dy_01mm) / 10, 1) for i in range(self.num_channels)] tip_waste_x = 0.0 - if self._driver.extended_conf is not None: - tip_waste_x = self._driver.extended_conf.tip_waste_x_position + if self.driver.extended_conf is not None: + tip_waste_x = self.driver.extended_conf.tip_waste_x_position await self.initialize_pipetting_channels( x_positions=[tip_waste_x], @@ -1311,19 +1323,22 @@ async def initialize_pipetting_channels( end_fw = round(end_of_tip_deposit_process * 10) z_end_fw = round(z_position_at_end_of_a_command * 10) - assert all(0 <= xp <= 25000 for xp in x_positions_fw), ( - "x_positions must be between 0 and 2500 mm" - ) - assert all(0 <= yp <= 6500 for yp in y_positions_fw), ( - "y_positions must be between 0 and 650 mm" - ) - assert 0 <= begin_fw <= 3600, "begin_of_tip_deposit_process must be between 0 and 360 mm" - assert 0 <= end_fw <= 3600, "end_of_tip_deposit_process must be between 0 and 360 mm" - assert 0 <= z_end_fw <= 3600, "z_position_at_end_of_a_command must be between 0 and 360 mm" - assert 0 <= tip_type <= 99, "tip_type must be between 0 and 99" - assert 0 <= discarding_method <= 1, "discarding_method must be between 0 and 1" - - return await self._driver.send_command( + if not all(0 <= xp <= 25000 for xp in x_positions_fw): + raise ValueError("x_positions must be between 0 and 2500 mm") + if not all(0 <= yp <= 6500 for yp in y_positions_fw): + raise ValueError("y_positions must be between 0 and 650 mm") + if not 0 <= begin_fw <= 3600: + raise ValueError("begin_of_tip_deposit_process must be between 0 and 360 mm") + if not 0 <= end_fw <= 3600: + raise ValueError("end_of_tip_deposit_process must be between 0 and 360 mm") + if not 0 <= z_end_fw <= 3600: + raise ValueError("z_position_at_end_of_a_command must be between 0 and 360 mm") + if not 0 <= tip_type <= 99: + raise ValueError("tip_type must be between 0 and 99") + if not 0 <= discarding_method <= 1: + raise ValueError("discarding_method must be between 0 and 1") + + return await self.driver.send_command( module="C0", command="DI", read_timeout=120, @@ -1347,11 +1362,12 @@ async def move_channel_z(self, channel: int, z: float): channel: 0-indexed channel index. z: Target Z position in mm. """ - assert 0 <= channel < self._driver.num_channels, \ - f"channel must be between 0 and {self._driver.num_channels - 1}" - assert 0 <= z <= 334.7, "z must be between 0 and 334.7 mm" + if not 0 <= channel < self.driver.num_channels: + raise ValueError(f"channel must be between 0 and {self.driver.num_channels - 1}") + if not 0 <= z <= 334.7: + raise ValueError("z must be between 0 and 334.7 mm") - return await self._driver.send_command( + return await self.driver.send_command( module="C0", command="KZ", pn=f"{channel + 1:02}", @@ -1364,7 +1380,7 @@ def _get_maximum_minimum_spacing_between_channels(self, use_channels: List[int]) """Get the maximum of the set of minimum spacing requirements between the channels being used.""" sorted_channels = sorted(use_channels) return max( - self._driver._min_spacing_between(hi, lo) + self.driver._min_spacing_between(hi, lo) for hi, lo in zip(sorted_channels[1:], sorted_channels[:-1]) ) @@ -1422,15 +1438,14 @@ async def pierce_foil( ) ys = [y + offset.y for offset in offsets] else: - assert len(set(w.get_location_wrt(deck).x for w in wells)) == 1, ( - "Wells must be on the same column" - ) + if len(set(w.get_location_wrt(deck).x for w in wells)) != 1: + raise ValueError("Wells must be on the same column") absolute_center = wells[0].get_location_wrt(deck, "c", "c", "cavity_bottom") x = absolute_center.x ys = [well.get_location_wrt(deck, x="c", y="c").y for well in wells] z = absolute_center.z - await self._driver.left_x_arm.move_to(x) + await self.driver.left_x_arm.move_to(x) await self.position_channels_in_y_direction( {channel: y for channel, y in zip(piercing_channels, ys)} @@ -1487,9 +1502,11 @@ async def step_off_foil( wells = [wells] plates = set(well.parent for well in wells) - assert len(plates) == 1, "All wells must be in the same plate" + if len(plates) != 1: + raise ValueError("All wells must be in the same plate") plate = plates.pop() - assert plate is not None + if plate is None: + raise ValueError("Wells must have a parent plate") z_location = plate.get_location_wrt(deck, z="top").z diff --git a/pylabrobot/hamilton/liquid_handlers/star/star.py b/pylabrobot/hamilton/liquid_handlers/star/star.py index 87e15733584..a4041733212 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/star.py +++ b/pylabrobot/hamilton/liquid_handlers/star/star.py @@ -5,7 +5,7 @@ from pylabrobot.arms.arm import GripperArm from pylabrobot.arms.orientable_arm import OrientableArm -from pylabrobot.capabilities.liquid_handling.head96 import Head96Capability +from pylabrobot.capabilities.liquid_handling.head96 import Head96 from pylabrobot.capabilities.liquid_handling.pip import PIP from pylabrobot.device import Device from pylabrobot.resources import Coordinate @@ -27,27 +27,27 @@ class STAR(Device): def __init__(self, deck: HamiltonDeck, chatterbox: bool = False): driver = STARChatterboxDriver() if chatterbox else STARDriver() super().__init__(driver=driver) - self._driver: STARDriver = driver + self.driver: STARDriver = driver self.deck = deck self.pip: PIP # set in setup() - self.head96: Optional[Head96Capability] = None # set in setup() if installed + self.head96: Optional[Head96] = None # set in setup() if installed self.iswap: Optional[OrientableArm] = None # set in setup() if installed async def setup(self): - await self._driver.setup() + await self.driver.setup() # PIP is always present. - self.pip = PIP(backend=self._driver.pip) + self.pip = PIP(backend=self.driver.pip) self._capabilities = [self.pip] # Head96 only if the hardware has a 96-head installed. - if self._driver.head96 is not None: - self.head96 = Head96Capability(backend=self._driver.head96) + if self.driver.head96 is not None: + self.head96 = Head96(backend=self.driver.head96) self._capabilities.append(self.head96) # iSWAP only if installed. - if self._driver.iswap is not None: - self.iswap = OrientableArm(backend=self._driver.iswap, reference_resource=self.deck) + if self.driver.iswap is not None: + self.iswap = OrientableArm(backend=self.driver.iswap, reference_resource=self.deck) self._capabilities.append(self.iswap) for cap in self._capabilities: @@ -57,7 +57,7 @@ async def setup(self): async def stop(self): for cap in reversed(self._capabilities): await cap._on_stop() - await self._driver.stop() + await self.driver.stop() self._setup_finished = False self.head96 = None self.iswap = None @@ -85,7 +85,8 @@ async def core_grippers( await self.iswap.backend.park() core_grippers_resource = self.deck.get_resource("core_grippers") - assert isinstance(core_grippers_resource, HamiltonCoreGrippers) + if not isinstance(core_grippers_resource, HamiltonCoreGrippers): + raise TypeError("core_grippers resource must be HamiltonCoreGrippers") back_channel = front_channel - 1 loc = core_grippers_resource.get_absolute_location() @@ -94,7 +95,7 @@ async def core_grippers( front_y = int(loc.y + core_grippers_resource.front_channel_y_center + front_offset.y) z_offset = front_offset.z - await self._driver.pick_up_core_gripper_tools( + await self.driver.pick_up_core_gripper_tools( x_position=xs, back_channel_y=back_y, front_channel_y=front_y, @@ -105,13 +106,13 @@ async def core_grippers( traversal_height=traversal_height, ) - backend = CoreGripper(driver=self._driver) + backend = CoreGripper(driver=self.driver) arm = GripperArm(backend=backend, reference_resource=self.deck, grip_axis="y") try: yield arm finally: - await self._driver.return_core_gripper_tools( + await self.driver.return_core_gripper_tools( x_position=xs, back_channel_y=back_y, front_channel_y=front_y, diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/autoload_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/autoload_tests.py index f774bc1c6af..eeead0c2dbb 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/tests/autoload_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/autoload_tests.py @@ -14,8 +14,8 @@ async def asyncSetUp(self): # -- initialization -------------------------------------------------------- - async def test_initialize(self): - await self.autoload.initialize() + async def test_on_setup(self): + await self.autoload._on_setup() self.mock_driver.send_command.assert_called_once_with(module="C0", command="II") async def test_request_initialization_status_true(self): @@ -116,9 +116,9 @@ async def test_request_presence_of_single_carrier_absent(self): ) async def test_request_presence_of_single_carrier_invalid_track(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.autoload.request_presence_of_single_carrier_on_loading_tray(track=0) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.autoload.request_presence_of_single_carrier_on_loading_tray(track=55) # -- movement commands ----------------------------------------------------- @@ -132,9 +132,9 @@ async def test_move_to_track(self): self.assertEqual(calls[1].kwargs, {"module": "I0", "command": "XP", "xp": "12"}) async def test_move_to_track_invalid(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.autoload.move_to_track(track=0) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.autoload.move_to_track(track=55) async def test_park(self): @@ -232,9 +232,9 @@ async def test_unload_carrier_no_park(self): ) async def test_unload_carrier_invalid_rail(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.autoload.unload_carrier(carrier_end_rail=0) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.autoload.unload_carrier(carrier_end_rail=55) # -- LED / monitoring ------------------------------------------------------ @@ -251,7 +251,7 @@ async def test_set_loading_indicators(self): ) async def test_set_loading_indicators_invalid_length(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.autoload.set_loading_indicators([True] * 10, [False] * 10) async def test_set_carrier_monitoring(self): diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/cover_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/cover_tests.py index 59dbd569693..0d6d68c79a7 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/tests/cover_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/cover_tests.py @@ -37,9 +37,9 @@ async def test_set_output_reserve(self): self.mock_driver.send_command.assert_called_once_with(module="C0", command="OS", on=2) async def test_set_output_invalid(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.cover.set_output(output=0) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.cover.set_output(output=4) async def test_reset_output(self): @@ -49,9 +49,9 @@ async def test_reset_output(self): ) async def test_reset_output_invalid(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.cover.reset_output(output=0) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.cover.reset_output(output=4) async def test_is_open_true(self): diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/legacy_parity_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/legacy_parity_tests.py deleted file mode 100644 index 0b69e309a6b..00000000000 --- a/pylabrobot/hamilton/liquid_handlers/star/tests/legacy_parity_tests.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Tests that verify new backends produce the same firmware commands as legacy. - -Sets up identical decks, runs operations through both, compares the firmware command strings. -""" - -import unittest -from unittest.mock import AsyncMock, MagicMock, call - -from pylabrobot.legacy.liquid_handling import LiquidHandler -from pylabrobot.legacy.liquid_handling.backends.hamilton.STAR_chatterbox import STARChatterboxBackend -from pylabrobot.resources import ( - TIP_CAR_480_A00, - PLT_CAR_L5AC_A00, - Cor_96_wellplate_360ul_Fb, -) -from pylabrobot.resources.hamilton import STARLetDeck, hamilton_96_tiprack_1000uL_filter - -from pylabrobot.hamilton.liquid_handlers.star.chatterbox import STARChatterboxDriver -from pylabrobot.hamilton.liquid_handlers.star.star import STAR -from pylabrobot.capabilities.liquid_handling.standard import Aspiration, Dispense, Pickup, TipDrop - - -class _CaptureDriver(STARChatterboxDriver): - """Captures firmware commands instead of printing them.""" - - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.commands = [] - - async def send_command(self, module, command, auto_id=True, tip_pattern=None, - write_timeout=None, read_timeout=None, wait=True, - fmt=None, **kwargs): - cmd, _ = self._assemble_command(module=module, command=command, - auto_id=auto_id, tip_pattern=tip_pattern, **kwargs) - self.commands.append(cmd) - return None - - -class TestLegacyParity(unittest.IsolatedAsyncioTestCase): - - async def asyncSetUp(self): - # --- Legacy setup --- - self.legacy_backend = STARChatterboxBackend() - self.legacy_backend._write_and_read_command = AsyncMock(return_value=None) - self.legacy_deck = STARLetDeck() - - tip_car = TIP_CAR_480_A00(name="tip_carrier") - tip_car[0] = hamilton_96_tiprack_1000uL_filter(name="tips_01") - self.legacy_deck.assign_child_resource(tip_car, rails=3) - - plt_car = PLT_CAR_L5AC_A00(name="plate_carrier") - plt_car[0] = Cor_96_wellplate_360ul_Fb(name="plate_01") - self.legacy_deck.assign_child_resource(plt_car, rails=15) - - self.lh = LiquidHandler(self.legacy_backend, deck=self.legacy_deck) - await self.lh.setup() - - # --- New setup --- - self.new_driver = _CaptureDriver() - self.new_deck = STARLetDeck() - - tip_car2 = TIP_CAR_480_A00(name="tip_carrier") - tip_car2[0] = hamilton_96_tiprack_1000uL_filter(name="tips_01") - self.new_deck.assign_child_resource(tip_car2, rails=3) - - plt_car2 = PLT_CAR_L5AC_A00(name="plate_carrier") - plt_car2[0] = Cor_96_wellplate_360ul_Fb(name="plate_01") - self.new_deck.assign_child_resource(plt_car2, rails=15) - - self.star = STAR(deck=self.new_deck, chatterbox=True) - # Replace the driver with our capture driver - self.star._driver = self.new_driver - await self.star._driver.setup() - from .pip_backend import STARPIPBackend - self.star._driver.pip = STARPIPBackend(self.new_driver) - from .head96_backend import STARHead96Backend - self.star._driver.head96 = STARHead96Backend(self.new_driver) - from pylabrobot.capabilities.liquid_handling.pip import PIP - from pylabrobot.capabilities.liquid_handling.head96 import Head96Capability - self.star.pip = PIP(backend=self.star._driver.pip) - self.star.head96 = Head96Capability(backend=self.star._driver.head96) - self.star._capabilities = [self.star.pip, self.star.head96] - for cap in self.star._capabilities: - await cap._on_setup() - self.star._setup_finished = True - - def _get_legacy_commands(self): - """Extract firmware command strings from legacy mock calls.""" - commands = [] - for c in self.legacy_backend._write_and_read_command.call_args_list: - cmd = c.kwargs.get("cmd") or c.args[1] - commands.append(cmd) - return commands - - def _assert_commands_match(self, legacy_cmds, new_cmds, label=""): - self.assertEqual(len(legacy_cmds), len(new_cmds), - f"{label} command count mismatch: legacy={len(legacy_cmds)}, new={len(new_cmds)}\n" - f"legacy: {legacy_cmds}\nnew: {new_cmds}") - for i, (leg, new) in enumerate(zip(legacy_cmds, new_cmds)): - # Strip the id (id####, 6 chars at position 4-9) since counters won't match - leg_no_id = leg[:4] + leg[10:] - new_no_id = new[:4] + new[10:] - self.assertEqual(leg_no_id, new_no_id, - f"{label} command {i} mismatch:\nlegacy: {leg}\nnew: {new}") - - async def test_pick_up_tips(self): - tiprack_legacy = self.legacy_deck.get_resource("tips_01") - tiprack_new = self.new_deck.get_resource("tips_01") - - self.legacy_backend._write_and_read_command.reset_mock() - await self.lh.pick_up_tips(tiprack_legacy["A1:C1"]) - legacy_cmds = self._get_legacy_commands() - - self.new_driver.commands.clear() - await self.star.pip.pick_up_tips(tiprack_new["A1:C1"]) - new_cmds = self.new_driver.commands - - self._assert_commands_match(legacy_cmds, new_cmds, "pick_up_tips") - - async def test_aspirate(self): - tiprack_legacy = self.legacy_deck.get_resource("tips_01") - tiprack_new = self.new_deck.get_resource("tips_01") - plate_legacy = self.legacy_deck.get_resource("plate_01") - plate_new = self.new_deck.get_resource("plate_01") - - # Pick up tips first (both sides) - await self.lh.pick_up_tips(tiprack_legacy["A1:C1"]) - await self.star.pip.pick_up_tips(tiprack_new["A1:C1"]) - - # Set volume so legacy doesn't complain - for well in plate_legacy.get_items(["A1", "B1", "C1"]): - well.tracker.set_volume(200) - for well in plate_new.get_items(["A1", "B1", "C1"]): - well.tracker.set_volume(200) - - # Aspirate - self.legacy_backend._write_and_read_command.reset_mock() - await self.lh.aspirate(plate_legacy["A1:C1"], vols=[100.0, 50.0, 200.0]) - legacy_cmds = self._get_legacy_commands() - - self.new_driver.commands.clear() - await self.star.pip.aspirate(plate_new["A1:C1"], vols=[100.0, 50.0, 200.0]) - new_cmds = self.new_driver.commands - - self._assert_commands_match(legacy_cmds, new_cmds, "aspirate") - - async def test_dispense(self): - tiprack_legacy = self.legacy_deck.get_resource("tips_01") - tiprack_new = self.new_deck.get_resource("tips_01") - plate_legacy = self.legacy_deck.get_resource("plate_01") - plate_new = self.new_deck.get_resource("plate_01") - - # Pick up tips + aspirate first - await self.lh.pick_up_tips(tiprack_legacy["A1:C1"]) - await self.star.pip.pick_up_tips(tiprack_new["A1:C1"]) - for well in plate_legacy.get_items(["A1", "B1", "C1"]): - well.tracker.set_volume(200) - for well in plate_new.get_items(["A1", "B1", "C1"]): - well.tracker.set_volume(200) - await self.lh.aspirate(plate_legacy["A1:C1"], vols=[100.0, 50.0, 200.0]) - await self.star.pip.aspirate(plate_new["A1:C1"], vols=[100.0, 50.0, 200.0]) - - # Dispense - self.legacy_backend._write_and_read_command.reset_mock() - await self.lh.dispense(plate_legacy["D1:F1"], vols=[100.0, 50.0, 200.0]) - legacy_cmds = self._get_legacy_commands() - - self.new_driver.commands.clear() - await self.star.pip.dispense(plate_new["D1:F1"], vols=[100.0, 50.0, 200.0]) - new_cmds = self.new_driver.commands - - self._assert_commands_match(legacy_cmds, new_cmds, "dispense") - - async def test_drop_tips(self): - tiprack_legacy = self.legacy_deck.get_resource("tips_01") - tiprack_new = self.new_deck.get_resource("tips_01") - - # Pick up tips first - await self.lh.pick_up_tips(tiprack_legacy["A1:C1"]) - await self.star.pip.pick_up_tips(tiprack_new["A1:C1"]) - - # Drop tips - self.legacy_backend._write_and_read_command.reset_mock() - await self.lh.drop_tips(tiprack_legacy["A1:C1"]) - legacy_cmds = self._get_legacy_commands() - - self.new_driver.commands.clear() - await self.star.pip.drop_tips(tiprack_new["A1:C1"]) - new_cmds = self.new_driver.commands - - self._assert_commands_match(legacy_cmds, new_cmds, "drop_tips") diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/wash_station_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/wash_station_tests.py index 205cf78e1bd..25e1314699c 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/tests/wash_station_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/wash_station_tests.py @@ -39,11 +39,11 @@ async def test_request_settings_station_3(self): ) async def test_request_settings_invalid_station_0(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.ws.request_settings(station=0) async def test_request_settings_invalid_station_4(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.ws.request_settings(station=4) # -- initialize_valves ------------------------------------------------------ @@ -67,11 +67,11 @@ async def test_initialize_valves_station_3(self): ) async def test_initialize_valves_invalid_station_0(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.ws.initialize_valves(station=0) async def test_initialize_valves_invalid_station_4(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.ws.initialize_valves(station=4) # -- fill_chamber ----------------------------------------------------------- @@ -158,27 +158,27 @@ async def test_fill_chamber_suck_time(self): ) async def test_fill_chamber_invalid_station_0(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.ws.fill_chamber(station=0) async def test_fill_chamber_invalid_station_4(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.ws.fill_chamber(station=4) async def test_fill_chamber_invalid_wash_fluid_0(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.ws.fill_chamber(wash_fluid=0) async def test_fill_chamber_invalid_wash_fluid_3(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.ws.fill_chamber(wash_fluid=3) async def test_fill_chamber_invalid_chamber_0(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.ws.fill_chamber(chamber=0) async def test_fill_chamber_invalid_chamber_3(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.ws.fill_chamber(chamber=3) # -- drain ------------------------------------------------------------------ @@ -202,9 +202,9 @@ async def test_drain_station_3(self): ) async def test_drain_invalid_station_0(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.ws.drain(station=0) async def test_drain_invalid_station_4(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.ws.drain(station=4) diff --git a/pylabrobot/hamilton/liquid_handlers/star/tests/x_arm_tests.py b/pylabrobot/hamilton/liquid_handlers/star/tests/x_arm_tests.py index 0f20a020f1b..901ef85f1fd 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/tests/x_arm_tests.py +++ b/pylabrobot/hamilton/liquid_handlers/star/tests/x_arm_tests.py @@ -139,15 +139,15 @@ async def test_right_last_collision_type_false(self): # -- assertion checks ------------------------------------------------------ async def test_move_to_rejects_out_of_range(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.left_arm.move_to(x_position=-1) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.left_arm.move_to(x_position=3001) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.right_arm.move_to(x_position=-1) async def test_move_to_safe_rejects_out_of_range(self): - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.left_arm.move_to_safe(x_position=-1) - with self.assertRaises(AssertionError): + with self.assertRaises(ValueError): await self.right_arm.move_to_safe(x_position=3001) diff --git a/pylabrobot/hamilton/liquid_handlers/star/wash_station.py b/pylabrobot/hamilton/liquid_handlers/star/wash_station.py index 984a21500b3..9387fd223d5 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/wash_station.py +++ b/pylabrobot/hamilton/liquid_handlers/star/wash_station.py @@ -21,7 +21,17 @@ class STARWashStation: """ def __init__(self, driver: "STARDriver"): - self._driver = driver + self.driver = driver + + # -- lifecycle ------------------------------------------------------------- + + async def _on_setup(self): + pass + + async def _on_stop(self): + pass + + # -- commands -------------------------------------------------------------- class Type(enum.IntEnum): """Pump station type enumeration.""" @@ -48,9 +58,10 @@ async def request_settings(self, station: int = 1) -> "Type": 5 = ReReRe (dual chamber) """ - assert 1 <= station <= 3, "station must be between 1 and 3" + if not 1 <= station <= 3: + raise ValueError("station must be between 1 and 3") - resp = await self._driver.send_command(module="C0", command="ET", fmt="et#", ep=station) + resp = await self.driver.send_command(module="C0", command="ET", fmt="et#", ep=station) return STARWashStation.Type(resp["et"]) async def initialize_valves(self, station: int = 1): @@ -60,9 +71,10 @@ async def initialize_valves(self, station: int = 1): station: pump station number (1..3). """ - assert 1 <= station <= 3, "station must be between 1 and 3" + if not 1 <= station <= 3: + raise ValueError("station must be between 1 and 3") - return await self._driver.send_command(module="C0", command="EJ", ep=station) + return await self.driver.send_command(module="C0", command="EJ", ep=station) async def fill_chamber( self, @@ -89,14 +101,17 @@ async def fill_chamber( change (for error handling only). """ - assert 1 <= station <= 3, "station must be between 1 and 3" - assert 1 <= wash_fluid <= 2, "wash_fluid must be between 1 and 2" - assert 1 <= chamber <= 2, "chamber must be between 1 and 2" + if not 1 <= station <= 3: + raise ValueError("station must be between 1 and 3") + if not 1 <= wash_fluid <= 2: + raise ValueError("wash_fluid must be between 1 and 2") + if not 1 <= chamber <= 2: + raise ValueError("chamber must be between 1 and 2") # wash fluid <-> chamber connection connection = {(1, 2): 0, (1, 1): 1, (2, 1): 2, (2, 2): 3}[wash_fluid, chamber] - return await self._driver.send_command( + return await self.driver.send_command( module="C0", command="EH", ep=station, @@ -113,6 +128,7 @@ async def drain(self, station: int = 1): station: pump station number (1..3). """ - assert 1 <= station <= 3, "station must be between 1 and 3" + if not 1 <= station <= 3: + raise ValueError("station must be between 1 and 3") - return await self._driver.send_command(module="C0", command="EL", ep=station) + return await self.driver.send_command(module="C0", command="EL", ep=station) diff --git a/pylabrobot/hamilton/liquid_handlers/star/x_arm.py b/pylabrobot/hamilton/liquid_handlers/star/x_arm.py index 0573c4d2a48..5f146c7dd97 100644 --- a/pylabrobot/hamilton/liquid_handlers/star/x_arm.py +++ b/pylabrobot/hamilton/liquid_handlers/star/x_arm.py @@ -23,9 +23,17 @@ class STARXArm: """ def __init__(self, driver: "STARDriver", side: Literal["left", "right"]): - self._driver = driver + self.driver = driver self._side = side + # -- lifecycle ------------------------------------------------------------- + + async def _on_setup(self): + pass + + async def _on_stop(self): + pass + # -- positioning (collision risk) ------------------------------------------ async def move_to(self, x_position: float = 0.0): @@ -37,10 +45,11 @@ async def move_to(self, x_position: float = 0.0): x_position: X-position in mm. Must be between 0 and 3000. Default 0. """ - assert 0 <= x_position <= 3000.0, "x_position must be between 0 and 3000 mm" + if not 0 <= x_position <= 3000.0: + raise ValueError("x_position must be between 0 and 3000 mm") cmd = "JX" if self._side == "left" else "JS" - return await self._driver.send_command( + return await self.driver.send_command( module="C0", command=cmd, xs=f"{round(x_position * 10):05}", @@ -56,10 +65,11 @@ async def move_to_safe(self, x_position: float = 0.0): x_position: X-position in mm. Must be between 0 and 3000. Default 0. """ - assert 0 <= x_position <= 3000.0, "x_position must be between 0 and 3000 mm" + if not 0 <= x_position <= 3000.0: + raise ValueError("x_position must be between 0 and 3000 mm") cmd = "KX" if self._side == "left" else "KR" - return await self._driver.send_command( + return await self.driver.send_command( module="C0", command=cmd, xs=round(x_position * 10), @@ -75,7 +85,7 @@ async def request_position(self) -> float: """ cmd = "RX" if self._side == "left" else "QX" - resp = await self._driver.send_command(module="C0", command=cmd, fmt="rx#####") + resp = await self.driver.send_command(module="C0", command=cmd, fmt="rx#####") return float(resp["rx"]) / 10 # -- collision type query -------------------------------------------------- @@ -89,5 +99,5 @@ async def last_collision_type(self) -> bool: """ cmd = "XX" if self._side == "left" else "XR" - resp = await self._driver.send_command(module="C0", command=cmd, fmt="xq#") + resp = await self.driver.send_command(module="C0", command=cmd, fmt="xq#") return resp["xq"] == 1 diff --git a/pylabrobot/hamilton/only_fans/backend.py b/pylabrobot/hamilton/only_fans/backend.py index 742e9538278..ec9a41925ea 100644 --- a/pylabrobot/hamilton/only_fans/backend.py +++ b/pylabrobot/hamilton/only_fans/backend.py @@ -149,16 +149,16 @@ class HamiltonHepaFanFanBackend(FanBackend): """Translates FanBackend calls into FTDI commands.""" def __init__(self, driver: HamiltonHepaFanDriver): - self._driver = driver + self.driver = driver async def turn_on(self, intensity: int) -> None: if int(intensity) != intensity or not 0 <= intensity <= 100: raise ValueError("Intensity must be an integer between 0 and 100") - await self._driver.send(b"\x35\x41\x01\xff\x75") - await self._driver.send(bytes.fromhex(_SPEED_TABLE[intensity])) + await self.driver.send(b"\x35\x41\x01\xff\x75") + await self.driver.send(bytes.fromhex(_SPEED_TABLE[intensity])) async def turn_off(self) -> None: - await self._driver.send(b"\x55\xc1\x01\x11\x00\x7b") + await self.driver.send(b"\x55\xc1\x01\x11\x00\x7b") class HamiltonHepaFanChatterboxBackend(FanBackend): diff --git a/pylabrobot/hamilton/only_fans/hepa_fan.py b/pylabrobot/hamilton/only_fans/hepa_fan.py index c533bef52a6..17ea48330b3 100644 --- a/pylabrobot/hamilton/only_fans/hepa_fan.py +++ b/pylabrobot/hamilton/only_fans/hepa_fan.py @@ -1,6 +1,6 @@ from typing import Optional -from pylabrobot.capabilities.fan_control import FanControlCapability +from pylabrobot.capabilities.fan_control import Fan from pylabrobot.device import Device from .backend import HamiltonHepaFanDriver, HamiltonHepaFanFanBackend @@ -12,6 +12,6 @@ class HamiltonHepaFan(Device): def __init__(self, name: str, device_id: Optional[str] = None): driver = HamiltonHepaFanDriver(device_id=device_id) super().__init__(driver=driver) - self._driver: HamiltonHepaFanDriver = driver - self.fan = FanControlCapability(backend=HamiltonHepaFanFanBackend(driver)) + self.driver: HamiltonHepaFanDriver = driver + self.fan = Fan(backend=HamiltonHepaFanFanBackend(driver)) self._capabilities = [self.fan] diff --git a/pylabrobot/hamilton/tilt_module/backend.py b/pylabrobot/hamilton/tilt_module/backend.py index d8df9686f07..f848df38f7c 100644 --- a/pylabrobot/hamilton/tilt_module/backend.py +++ b/pylabrobot/hamilton/tilt_module/backend.py @@ -91,7 +91,7 @@ class HamiltonTiltModuleTilterBackend(TilterBackend): """ def __init__(self, driver: HamiltonTiltModuleDriver): - self._driver = driver + self.driver = driver async def _on_setup(self): await self.tilt_initial_offset(0) @@ -107,7 +107,7 @@ async def set_angle(self, angle: float): async def tilt_initialize(self): """Initialize a daisy chained tilt module.""" - return await self._driver.send_command("SI") + return await self.driver.send_command("SI") async def tilt_move_to_absolute_step_position(self, position: float): """Move the tilt module to an absolute position. @@ -118,7 +118,7 @@ async def tilt_move_to_absolute_step_position(self, position: float): assert -10 <= position <= 120, "Position must be between -10 and 120." - return await self._driver.send_command( + return await self.driver.send_command( command="SA", parameter=str(position), ) @@ -134,7 +134,7 @@ async def tilt_move_to_relative_step_position(self, steps: float): assert -10000 <= steps <= 10000, "Steps must be between -10000 and 10000." - return await self._driver.send_command(command="SR", parameter=str(steps)) + return await self.driver.send_command(command="SR", parameter=str(steps)) async def tilt_go_to_position(self, position: int): """Go to position (0...10). @@ -145,7 +145,7 @@ async def tilt_go_to_position(self, position: int): assert 0 <= position <= 10, "Position must be between 0 and 10." - return await self._driver.send_command(command="GP", parameter=str(position)) + return await self.driver.send_command(command="GP", parameter=str(position)) async def tilt_set_speed(self, speed: int): """Set the speed on the tilt module. @@ -156,12 +156,12 @@ async def tilt_set_speed(self, speed: int): assert 1 <= speed <= 9, "Speed must be between 1 and 9." - return await self._driver.send_command(command="SV", parameter=str(speed)) + return await self.driver.send_command(command="SV", parameter=str(speed)) async def tilt_power_off(self): """Power off the tilt module.""" - return await self._driver.send_command(command="PO") + return await self.driver.send_command(command="PO") async def tilt_request_error(self) -> Optional[str]: """Request the error of the tilt module. @@ -170,7 +170,7 @@ async def tilt_request_error(self) -> Optional[str]: """ # send_command will automatically raise an error, if one exists - return await self._driver.send_command("RE") + return await self.driver.send_command("RE") async def tilt_request_sensor(self) -> Optional[str]: """Request sensor status. @@ -180,7 +180,7 @@ async def tilt_request_sensor(self) -> Optional[str]: 6 = NPN Input 1, 7 = NPN Input 2 """ - resp = await self._driver.send_command(command="RX") + resp = await self.driver.send_command(command="RX") resp = resp[:-2].split(" ")[1] code = int(resp) @@ -202,7 +202,7 @@ async def tilt_request_sensor(self) -> Optional[str]: async def tilt_request_offset_between_light_barrier_and_init_position(self) -> int: """Request Offset between Light Barrier and Init Position""" - resp = await self._driver.send_command(command="RO") + resp = await self.driver.send_command(command="RO") resp = resp[:-2].split(" ")[1] return int(resp) @@ -215,7 +215,7 @@ async def tilt_port_set_open_collector(self, open_collector: int): assert 1 <= open_collector <= 8, "open_collector must be between 1 and 8" - return await self._driver.send_command(command="PS", parameter=str(open_collector)) + return await self.driver.send_command(command="PS", parameter=str(open_collector)) async def tilt_port_clear_open_collector(self, open_collector: int): """Tilt port clear open collector. @@ -226,7 +226,7 @@ async def tilt_port_clear_open_collector(self, open_collector: int): assert 1 <= open_collector <= 8, "open_collector must be between 1 and 8" - return await self._driver.send_command(command="PC", parameter=str(open_collector)) + return await self.driver.send_command(command="PC", parameter=str(open_collector)) async def tilt_set_temperature(self, temperature: float): """Set the temperature (10-50 degrees C). @@ -237,12 +237,12 @@ async def tilt_set_temperature(self, temperature: float): assert 10 <= temperature <= 50, "Temperature must be between 10 and 50." - return await self._driver.send_command(command="ST", parameter=str(int(temperature * 10))) + return await self.driver.send_command(command="ST", parameter=str(int(temperature * 10))) async def tilt_switch_off_temperature_controller(self): """Switch off the temperature controller on the tilt module.""" - return await self._driver.send_command(command="TO") + return await self.driver.send_command(command="TO") async def tilt_set_drain_time(self, drain_time: float): """Set the drain time on the tilt module. @@ -253,17 +253,17 @@ async def tilt_set_drain_time(self, drain_time: float): assert 5 <= drain_time <= 250, "Drain time must be between 5 and 250." - return await self._driver.send_command(command="DT", parameter=str(int(drain_time * 10))) + return await self.driver.send_command(command="DT", parameter=str(int(drain_time * 10))) async def tilt_set_waste_pump_on(self): """Turn the waste pump on.""" - return await self._driver.send_command(command="WP") + return await self.driver.send_command(command="WP") async def tilt_set_waste_pump_off(self): """Turn the waste pump off.""" - return await self._driver.send_command(command="WO") + return await self.driver.send_command(command="WO") async def tilt_set_name(self, name: str): """Set the tilt module name. @@ -274,7 +274,7 @@ async def tilt_set_name(self, name: str): assert len(name) == 2, "name must be 2 characters long" - return await self._driver.send_command(command="MN", parameter=name) + return await self.driver.send_command(command="MN", parameter=name) async def tilt_switch_encoder(self, on: bool): """Switch the encoder on or off. @@ -283,7 +283,7 @@ async def tilt_switch_encoder(self, on: bool): on: if True, the encoder will be turned on, else off. """ - return await self._driver.send_command(command="EN", parameter=str(int(on))) + return await self.driver.send_command(command="EN", parameter=str(int(on))) async def tilt_initial_offset(self, offset: int): """Set the initial offset on the tilt module. @@ -294,7 +294,7 @@ async def tilt_initial_offset(self, offset: int): assert -100 <= offset <= 100, "Offset must be between -100 and 100." - return await self._driver.send_command(command="SO", parameter=str(offset)) + return await self.driver.send_command(command="SO", parameter=str(offset)) class HamiltonTiltModuleChatterboxTilterBackend(TilterBackend): diff --git a/pylabrobot/hamilton/tilt_module/tilt_module.py b/pylabrobot/hamilton/tilt_module/tilt_module.py index 70d56df8492..ff350c694da 100644 --- a/pylabrobot/hamilton/tilt_module/tilt_module.py +++ b/pylabrobot/hamilton/tilt_module/tilt_module.py @@ -1,7 +1,7 @@ import math from typing import List, Optional -from pylabrobot.capabilities.tilting import TiltingCapability +from pylabrobot.capabilities.tilting import Tilter from pylabrobot.device import Device from pylabrobot.resources import Coordinate, Plate from pylabrobot.resources.resource_holder import ResourceHolder @@ -38,11 +38,11 @@ def __init__( model="HamiltonTiltModule", ) Device.__init__(self, driver=driver) - self._driver: HamiltonTiltModuleDriver = driver + self.driver: HamiltonTiltModuleDriver = driver self.pedestal_size_z = pedestal_size_z self._hinge_coordinate = Coordinate(6.18, 0, 72.85) - self.tilter = TiltingCapability(backend=HamiltonTiltModuleTilterBackend(driver=driver)) + self.tilter = Tilter(backend=HamiltonTiltModuleTilterBackend(driver=driver)) self._capabilities = [self.tilter] @property diff --git a/pylabrobot/inheco/cpac.py b/pylabrobot/inheco/cpac.py index 1d74b8c3c54..6977f7306a9 100644 --- a/pylabrobot/inheco/cpac.py +++ b/pylabrobot/inheco/cpac.py @@ -3,7 +3,7 @@ from typing import Optional from pylabrobot.capabilities.temperature_controlling import ( - TemperatureControlCapability, + TemperatureController, TemperatureControllerBackend, ) from pylabrobot.device import Device, Driver @@ -117,8 +117,8 @@ def __init__( model=model, ) Device.__init__(self, driver=driver) - self._driver: InhecoCPACBackend = driver - self.tc = TemperatureControlCapability(backend=driver) + self.driver: InhecoCPACBackend = driver + self.tc = TemperatureController(backend=driver) self._capabilities = [self.tc] def serialize(self) -> dict: diff --git a/pylabrobot/inheco/scila/scila.py b/pylabrobot/inheco/scila/scila.py index 30b8880266e..c730bf7c7fa 100644 --- a/pylabrobot/inheco/scila/scila.py +++ b/pylabrobot/inheco/scila/scila.py @@ -1,6 +1,6 @@ from typing import Optional -from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.capabilities.temperature_controlling import TemperatureController from pylabrobot.device import Device from .scila_backend import SCILADriver, SCILATemperatureBackend @@ -13,8 +13,8 @@ def __init__(self, name: str, scila_ip: str, client_ip: Optional[str] = None): raise NotImplementedError("SCILA is missing resource definition.") driver = SCILADriver(scila_ip=scila_ip, client_ip=client_ip) Device.__init__(self, driver=driver) - self._driver: SCILADriver = driver - self.tc = TemperatureControlCapability(backend=SCILATemperatureBackend(driver=driver)) + self.driver: SCILADriver = driver + self.tc = TemperatureController(backend=SCILATemperatureBackend(driver=driver)) self._capabilities = [self.tc] def serialize(self) -> dict: diff --git a/pylabrobot/inheco/scila/scila_backend.py b/pylabrobot/inheco/scila/scila_backend.py index f30ce311d43..326381e4296 100644 --- a/pylabrobot/inheco/scila/scila_backend.py +++ b/pylabrobot/inheco/scila/scila_backend.py @@ -124,18 +124,18 @@ class SCILATemperatureBackend(TemperatureControllerBackend): """Translates TemperatureControllerBackend interface into SCILA SiLA commands.""" def __init__(self, driver: SCILADriver) -> None: - self._driver = driver + self.driver = driver @property def supports_active_cooling(self) -> bool: return False async def request_temperature_information(self) -> dict[str, Any]: - root = await self._driver.send_command("GetTemperature") + root = await self.driver.send_command("GetTemperature") return _get_params(root, ["CurrentTemperature", "TargetTemperature", "TemperatureControl"]) # type: ignore async def set_temperature(self, temperature: float) -> None: - await self._driver.send_command( + await self.driver.send_command( "SetTemperature", targetTemperature=temperature, temperatureControl=True ) @@ -143,7 +143,7 @@ async def request_current_temperature(self) -> float: return (await self.request_temperature_information())["CurrentTemperature"] # type: ignore async def deactivate(self) -> None: - await self._driver.send_command("SetTemperature", temperatureControl=False) + await self.driver.send_command("SetTemperature", temperatureControl=False) async def request_target_temperature(self) -> float: return (await self.request_temperature_information())["TargetTemperature"] # type: ignore diff --git a/pylabrobot/inheco/thermoshake.py b/pylabrobot/inheco/thermoshake.py index c38e2a48b43..2c1304a3c73 100644 --- a/pylabrobot/inheco/thermoshake.py +++ b/pylabrobot/inheco/thermoshake.py @@ -1,8 +1,8 @@ import warnings from typing import Optional -from pylabrobot.capabilities.shaking import ShakerBackend, ShakingCapability -from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.capabilities.shaking import ShakerBackend, Shaker +from pylabrobot.capabilities.temperature_controlling import TemperatureController from pylabrobot.device import Device from pylabrobot.resources import Coordinate, ResourceHolder @@ -98,9 +98,9 @@ def __init__( model=model, ) Device.__init__(self, driver=driver) - self._driver: InhecoThermoshakeBackend = driver - self.tc = TemperatureControlCapability(backend=driver) - self.shaker = ShakingCapability(backend=driver) + self.driver: InhecoThermoshakeBackend = driver + self.tc = TemperatureController(backend=driver) + self.shaker = Shaker(backend=driver) self._capabilities = [self.tc, self.shaker] def serialize(self) -> dict: diff --git a/pylabrobot/keyence/keyence_backend.py b/pylabrobot/keyence/keyence_backend.py index 82c7eabcfa5..b71ddb751b3 100644 --- a/pylabrobot/keyence/keyence_backend.py +++ b/pylabrobot/keyence/keyence_backend.py @@ -75,14 +75,14 @@ class KeyenceBarcodeScannerBarcodeScanningBackend(BarcodeScannerBackend): def __init__(self, driver: KeyenceBarcodeScannerDriver): super().__init__() - self._driver = driver + self.driver = driver async def _on_setup(self): """Initialize the barcode scanner motor after the driver connects.""" deadline = time.time() + self.init_timeout while time.time() < deadline: - response = await self._driver.send_command("RMOTOR") + response = await self.driver.send_command("RMOTOR") if response.strip() == "MOTORON": logger.info("Barcode scanner motor is ON.") break @@ -95,7 +95,7 @@ async def _on_setup(self): ) async def scan_barcode(self) -> Barcode: - data = await self._driver.send_command("LON") + data = await self.driver.send_command("LON") if data.startswith("NG"): raise BarcodeScannerError("Barcode reader is off: cannot read barcode") if data.startswith("ERR99"): diff --git a/pylabrobot/keyence/keyence_barcode_scanner.py b/pylabrobot/keyence/keyence_barcode_scanner.py index 9318e321bb4..8355ee48d71 100644 --- a/pylabrobot/keyence/keyence_barcode_scanner.py +++ b/pylabrobot/keyence/keyence_barcode_scanner.py @@ -1,4 +1,4 @@ -from pylabrobot.capabilities.barcode_scanning import BarcodeScanningCapability +from pylabrobot.capabilities.barcode_scanning import BarcodeScanner from pylabrobot.device import Device from .keyence_backend import ( @@ -13,8 +13,8 @@ class KeyenceBarcodeScanner(Device): def __init__(self, port: str): driver = KeyenceBarcodeScannerDriver(port=port) super().__init__(driver=driver) - self._driver: KeyenceBarcodeScannerDriver = driver - self.barcode_scanning = BarcodeScanningCapability( + self.driver: KeyenceBarcodeScannerDriver = driver + self.barcode_scanning = BarcodeScanner( backend=KeyenceBarcodeScannerBarcodeScanningBackend(driver) ) self._capabilities = [self.barcode_scanning] diff --git a/pylabrobot/legacy/barcode_scanners/barcode_scanner.py b/pylabrobot/legacy/barcode_scanners/barcode_scanner.py index b3557daf2aa..045c74246dc 100644 --- a/pylabrobot/legacy/barcode_scanners/barcode_scanner.py +++ b/pylabrobot/legacy/barcode_scanners/barcode_scanner.py @@ -8,7 +8,7 @@ class BarcodeScanner(Machine): """Legacy standalone barcode scanner Machine. - In new code, use BarcodeScanningCapability instead. + In new code, use BarcodeScanner instead. """ def __init__(self, backend: BarcodeScannerBackend): diff --git a/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py b/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py index 2c7384d4bc2..43c9f5b8edc 100644 --- a/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py +++ b/pylabrobot/legacy/barcode_scanners/keyence/keyence_backend.py @@ -16,16 +16,16 @@ class KeyenceBarcodeScannerBackend(BarcodeScannerBackend): def __init__(self, port: str): super().__init__() - self._driver = KeyenceBarcodeScannerDriver(port=port) - self._barcode_scanning = KeyenceBarcodeScannerBarcodeScanningBackend(self._driver) + self.driver = KeyenceBarcodeScannerDriver(port=port) + self._barcode_scanning = KeyenceBarcodeScannerBarcodeScanningBackend(self.driver) async def setup(self): - await self._driver.setup() + await self.driver.setup() await self._barcode_scanning._on_setup() async def stop(self): await self._barcode_scanning._on_stop() - await self._driver.stop() + await self.driver.stop() async def scan_barcode(self) -> Barcode: return await self._barcode_scanning.scan_barcode() diff --git a/pylabrobot/legacy/centrifuge/vspin_backend.py b/pylabrobot/legacy/centrifuge/vspin_backend.py index 3a29e9d236f..ebea3846dd2 100644 --- a/pylabrobot/legacy/centrifuge/vspin_backend.py +++ b/pylabrobot/legacy/centrifuge/vspin_backend.py @@ -14,51 +14,51 @@ class Access2Backend(LoaderBackend): """Legacy. Use pylabrobot.agilent.vspin.Access2Driver instead.""" def __init__(self, device_id: str, timeout: int = 60): - self._driver = _new.Access2Driver(device_id=device_id, timeout=timeout) + self.driver = _new.Access2Driver(device_id=device_id, timeout=timeout) @property def io(self): - return self._driver.io + return self.driver.io @io.setter def io(self, value): - self._driver.io = value + self.driver.io = value @property def timeout(self): - return self._driver.timeout + return self.driver.timeout @timeout.setter def timeout(self, value): - self._driver.timeout = value + self.driver.timeout = value async def setup(self): - await self._driver.setup() + await self.driver.setup() async def stop(self): - await self._driver.stop() + await self.driver.stop() def serialize(self): return {"io": self.io.serialize(), "timeout": self.timeout} async def send_command(self, command: bytes) -> bytes: - return await self._driver.send_command(command) + return await self.driver.send_command(command) async def get_status(self) -> bytes: - return await self._driver.request_status() + return await self.driver.request_status() async def park(self): - await self._driver.park() + await self.driver.park() async def close(self): - await self._driver.close() + await self.driver.close() async def open(self): - await self._driver.open() + await self.driver.open() async def load(self): try: - await self._driver.load() + await self.driver.load() except RuntimeError as e: if "no plate found on stage" in str(e): raise LoaderNoPlateError("no plate found on stage") from e @@ -66,7 +66,7 @@ async def load(self): async def unload(self): try: - await self._driver.unload() + await self.driver.unload() except RuntimeError as e: if "no plate found in centrifuge" in str(e): raise LoaderNoPlateError("no plate found in centrifuge") from e @@ -77,16 +77,16 @@ class VSpinBackend(CentrifugeBackend): """Legacy. Use pylabrobot.agilent.vspin.VSpinDriver instead.""" def __init__(self, device_id: Optional[str] = None): - self._driver = _new.VSpinDriver(device_id=device_id) - self._centrifuge = _new.VSpinCentrifugeBackend(self._driver) + self.driver = _new.VSpinDriver(device_id=device_id) + self._centrifuge = _new.VSpinCentrifugeBackend(self.driver) @property def io(self): - return self._driver.io + return self.driver.io @io.setter def io(self, value): - self._driver.io = value + self.driver.io = value @property def _bucket_1_remainder(self): @@ -101,12 +101,12 @@ def bucket_1_remainder(self) -> int: return self._centrifuge.bucket_1_remainder async def setup(self): - await self._driver.setup() + await self.driver.setup() await self._centrifuge._on_setup() async def stop(self): await self._centrifuge._on_stop() - await self._driver.stop() + await self.driver.stop() async def set_bucket_1_position_to_current(self) -> None: await self._centrifuge.set_bucket_1_position_to_current() @@ -115,22 +115,22 @@ async def get_bucket_1_position(self) -> int: return await self._centrifuge.request_bucket_1_position() async def get_position(self) -> int: - return await self._driver.request_position() + return await self.driver.request_position() async def get_tachometer(self) -> int: - return await self._driver.request_tachometer() + return await self.driver.request_tachometer() async def get_home_position(self) -> int: - return await self._driver.request_home_position() + return await self.driver.request_home_position() async def get_bucket_locked(self) -> bool: - return await self._driver.request_bucket_locked() + return await self.driver.request_bucket_locked() async def get_door_open(self) -> bool: - return await self._driver.request_door_open() + return await self.driver.request_door_open() async def get_door_locked(self) -> bool: - return await self._driver.request_door_locked() + return await self.driver.request_door_locked() async def open_door(self): await self._centrifuge.open_door() @@ -179,7 +179,7 @@ async def spin( ) async def configure_and_initialize(self): - await self._driver.configure_and_initialize() + await self.driver.configure_and_initialize() # Deprecated alias diff --git a/pylabrobot/legacy/heating_shaking/bioshake_backend.py b/pylabrobot/legacy/heating_shaking/bioshake_backend.py index e167741af86..8879cb98ee9 100644 --- a/pylabrobot/legacy/heating_shaking/bioshake_backend.py +++ b/pylabrobot/legacy/heating_shaking/bioshake_backend.py @@ -12,9 +12,9 @@ class BioShake(HeaterShakerBackend): """Legacy. Use pylabrobot.qinstruments.BioShakeDriver instead.""" def __init__(self, port: str, timeout: int = 60): - self._driver = BioShakeDriver(port=port, timeout=timeout) - self._shaker = BioShakeShakerBackend(self._driver) - self._temp = BioShakeTemperatureBackend(self._driver) + self.driver = BioShakeDriver(port=port, timeout=timeout) + self._shaker = BioShakeShakerBackend(self.driver) + self._temp = BioShakeTemperatureBackend(self.driver) @property def supports_active_cooling(self) -> bool: @@ -25,19 +25,19 @@ def supports_locking(self) -> bool: return self._shaker.supports_locking async def setup(self, skip_home: bool = False): - await self._driver.setup(skip_home=skip_home) + await self.driver.setup(skip_home=skip_home) async def stop(self): - await self._driver.stop() + await self.driver.stop() def serialize(self) -> dict: - return self._driver.serialize() + return self.driver.serialize() async def reset(self): - await self._driver.reset() + await self.driver.reset() async def home(self): - await self._driver.home() + await self.driver.home() async def start_shaking(self, speed: float, acceleration: int = 0): await self._shaker.start_shaking(speed=speed, acceleration=acceleration) diff --git a/pylabrobot/legacy/heating_shaking/hamilton_backend.py b/pylabrobot/legacy/heating_shaking/hamilton_backend.py index a737d83f656..e5f1b24c4a3 100644 --- a/pylabrobot/legacy/heating_shaking/hamilton_backend.py +++ b/pylabrobot/legacy/heating_shaking/hamilton_backend.py @@ -15,9 +15,9 @@ class HamiltonHeaterShakerBackend(HeaterShakerBackend): """Legacy. Use pylabrobot.hamilton.heater_shaker instead.""" def __init__(self, index: int, interface: HamiltonHeaterShakerInterface) -> None: - self._driver = hhs_backend.HamiltonHeaterShakerDriver(index=index, interface=interface) - self._shaker = hhs_backend.HamiltonHeaterShakerShakerBackend(self._driver) - self._temp = hhs_backend.HamiltonHeaterShakerTemperatureBackend(self._driver) + self.driver = hhs_backend.HamiltonHeaterShakerDriver(index=index, interface=interface) + self._shaker = hhs_backend.HamiltonHeaterShakerShakerBackend(self.driver) + self._temp = hhs_backend.HamiltonHeaterShakerTemperatureBackend(self.driver) @property def supports_active_cooling(self) -> bool: @@ -28,17 +28,17 @@ def supports_locking(self) -> bool: return self._shaker.supports_locking async def setup(self): - await self._driver.setup() + await self.driver.setup() await self._shaker._on_setup() await self._temp._on_setup() async def stop(self): await self._temp._on_stop() await self._shaker._on_stop() - await self._driver.stop() + await self.driver.stop() def serialize(self) -> dict: - return self._driver.serialize() + return self.driver.serialize() async def start_shaking( self, diff --git a/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py index 24fe863e4cb..d24bb8d2216 100644 --- a/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py +++ b/pylabrobot/legacy/liquid_handling/backends/hamilton/STAR_backend.py @@ -3929,7 +3929,7 @@ async def prepare_for_manual_channel_operation(self, channel: int): await self.position_max_free_y_for_n(pipetting_channel_index=channel) async def move_channel_x(self, channel: int, x: float): - """Deprecated: use ``star._driver.left_x_arm.move_to()``.""" + """Deprecated: use ``star.driver.left_x_arm.move_to()``.""" await self._new_left_x_arm.move_to(x) @need_iswap_parked @@ -4146,7 +4146,7 @@ def _check_96_position_legal(self, c: Coordinate, skip_z=False) -> None: # -------------- 3.2 System general commands -------------- async def pre_initialize_instrument(self): - """Deprecated: use ``star._driver.pre_initialize_instrument()``.""" + """Deprecated: use ``star.driver.pre_initialize_instrument()``.""" return await self.send_command(module="C0", command="VI", read_timeout=300) async def define_tip_needle( @@ -4191,17 +4191,17 @@ async def define_tip_needle( # -------------- 3.2.1 System query -------------- async def request_error_code(self): - """Deprecated: use ``star._driver.request_error_code()``.""" + """Deprecated: use ``star.driver.request_error_code()``.""" return await self.send_command(module="C0", command="RE") async def request_firmware_version(self): - """Deprecated: use ``star._driver.request_firmware_version()``.""" + """Deprecated: use ``star.driver.request_firmware_version()``.""" return await self.send_command(module="C0", command="RF") async def request_parameter_value(self): - """Deprecated: use ``star._driver.request_parameter_value()``.""" + """Deprecated: use ``star.driver.request_parameter_value()``.""" return await self.send_command(module="C0", command="RA") @@ -4213,7 +4213,7 @@ class BoardType(enum.Enum): UNKNOWN = -1 async def request_electronic_board_type(self): - """Deprecated: use ``star._driver.request_electronic_board_type()``.""" + """Deprecated: use ``star.driver.request_electronic_board_type()``.""" resp = await self.send_command(module="C0", command="QB") try: @@ -4222,12 +4222,12 @@ async def request_electronic_board_type(self): return STARBackend.BoardType.UNKNOWN async def request_supply_voltage(self): - """Deprecated: use ``star._driver.request_supply_voltage()``.""" + """Deprecated: use ``star.driver.request_supply_voltage()``.""" return await self.send_command(module="C0", command="MU") async def request_instrument_initialization_status(self) -> bool: - """Deprecated: use ``star._driver.request_instrument_initialization_status()``.""" + """Deprecated: use ``star.driver.request_instrument_initialization_status()``.""" resp = await self.send_command(module="C0", command="QW", fmt="qw#") return resp is not None and resp["qw"] == 1 @@ -4237,23 +4237,23 @@ async def request_autoload_initialization_status(self) -> bool: return await self._new_autoload.request_initialization_status() async def request_name_of_last_faulty_parameter(self): - """Deprecated: use ``star._driver.request_name_of_last_faulty_parameter()``.""" + """Deprecated: use ``star.driver.request_name_of_last_faulty_parameter()``.""" return await self.send_command(module="C0", command="VP", fmt="vp&&") async def request_master_status(self): - """Deprecated: use ``star._driver.request_master_status()``.""" + """Deprecated: use ``star.driver.request_master_status()``.""" return await self.send_command(module="C0", command="RQ") async def request_number_of_presence_sensors_installed(self): - """Deprecated: use ``star._driver.request_number_of_presence_sensors_installed()``.""" + """Deprecated: use ``star.driver.request_number_of_presence_sensors_installed()``.""" resp = await self.send_command(module="C0", command="SR") return resp["sr"] async def request_eeprom_data_correctness(self): - """Deprecated: use ``star._driver.request_eeprom_data_correctness()``.""" + """Deprecated: use ``star.driver.request_eeprom_data_correctness()``.""" return await self.send_command(module="C0", command="QV") @@ -4262,7 +4262,7 @@ async def request_eeprom_data_correctness(self): # -------------- 3.3.1 Volatile Settings -------------- async def set_single_step_mode(self, single_step_mode: bool = False): - """Deprecated: use ``star._driver.set_single_step_mode()``.""" + """Deprecated: use ``star.driver.set_single_step_mode()``.""" return await self.send_command( module="C0", @@ -4271,23 +4271,23 @@ async def set_single_step_mode(self, single_step_mode: bool = False): ) async def trigger_next_step(self): - """Deprecated: use ``star._driver.trigger_next_step()``.""" + """Deprecated: use ``star.driver.trigger_next_step()``.""" # TODO: this command has no reply!!!! return await self.send_command(module="C0", command="NS") async def halt(self): - """Deprecated: use ``star._driver.halt()``.""" + """Deprecated: use ``star.driver.halt()``.""" return await self.send_command(module="C0", command="HD") async def save_all_cycle_counters(self): - """Deprecated: use ``star._driver.save_all_cycle_counters()``.""" + """Deprecated: use ``star.driver.save_all_cycle_counters()``.""" return await self.send_command(module="C0", command="AZ") async def set_not_stop(self, non_stop): - """Deprecated: use ``star._driver.set_not_stop()``.""" + """Deprecated: use ``star.driver.set_not_stop()``.""" if non_stop: # TODO: this command has no reply!!!! @@ -4302,7 +4302,7 @@ async def store_installation_data( date: datetime.datetime = datetime.datetime.now(), serial_number: str = "0000", ): - """Deprecated: use ``star._driver.store_installation_data()``.""" + """Deprecated: use ``star.driver.store_installation_data()``.""" assert len(serial_number) == 4, "serial number must be 4 chars long" @@ -4314,7 +4314,7 @@ async def store_verification_data( date: datetime.datetime = datetime.datetime.now(), verification_status: bool = False, ): - """Deprecated: use ``star._driver.store_verification_data()``.""" + """Deprecated: use ``star.driver.store_verification_data()``.""" assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" @@ -4327,27 +4327,27 @@ async def store_verification_data( ) async def additional_time_stamp(self): - """Deprecated: use ``star._driver.additional_time_stamp()``.""" + """Deprecated: use ``star.driver.additional_time_stamp()``.""" return await self.send_command(module="C0", command="AT") async def set_x_offset_x_axis_iswap(self, x_offset: int): - """Deprecated: use ``star._driver.set_x_offset_x_axis_iswap()``.""" + """Deprecated: use ``star.driver.set_x_offset_x_axis_iswap()``.""" return await self.send_command(module="C0", command="AG", x_offset=x_offset) async def set_x_offset_x_axis_core_96_head(self, x_offset: int): - """Deprecated: use ``star._driver.set_x_offset_x_axis_core_96_head()``.""" + """Deprecated: use ``star.driver.set_x_offset_x_axis_core_96_head()``.""" return await self.send_command(module="C0", command="AF", x_offset=x_offset) async def set_x_offset_x_axis_core_nano_pipettor_head(self, x_offset: int): - """Deprecated: use ``star._driver.set_x_offset_x_axis_core_nano_pipettor_head()``.""" + """Deprecated: use ``star.driver.set_x_offset_x_axis_core_nano_pipettor_head()``.""" return await self.send_command(module="C0", command="AF", x_offset=x_offset) async def save_download_date(self, date: datetime.datetime = datetime.datetime.now()): - """Deprecated: use ``star._driver.save_download_date()``.""" + """Deprecated: use ``star.driver.save_download_date()``.""" return await self.send_command( module="C0", @@ -4356,7 +4356,7 @@ async def save_download_date(self, date: datetime.datetime = datetime.datetime.n ) async def save_technical_status_of_assemblies(self, processor_board: str, power_supply: str): - """Deprecated: use ``star._driver.save_technical_status_of_assemblies()``.""" + """Deprecated: use ``star.driver.save_technical_status_of_assemblies()``.""" return await self.send_command( module="C0", @@ -4388,7 +4388,7 @@ async def set_instrument_configuration( left_arm_minimal_y_position: int = 60, right_arm_minimal_y_position: int = 60, ): - """Deprecated: use ``star._driver.set_instrument_configuration()``.""" + """Deprecated: use ``star.driver.set_instrument_configuration()``.""" assert 1 <= instrument_size_in_slots_x_range <= 9, ( "instrument_size_in_slots_x_range must be between 1 and 99" @@ -4456,7 +4456,7 @@ async def set_instrument_configuration( ) async def save_pip_channel_validation_status(self, validation_status: bool = False): - """Deprecated: use ``star._driver.save_pip_channel_validation_status()``.""" + """Deprecated: use ``star.driver.save_pip_channel_validation_status()``.""" return await self.send_command( module="C0", @@ -4465,7 +4465,7 @@ async def save_pip_channel_validation_status(self, validation_status: bool = Fal ) async def save_xl_channel_validation_status(self, validation_status: bool = False): - """Deprecated: use ``star._driver.save_xl_channel_validation_status()``.""" + """Deprecated: use ``star.driver.save_xl_channel_validation_status()``.""" return await self.send_command( module="C0", @@ -4475,12 +4475,12 @@ async def save_xl_channel_validation_status(self, validation_status: bool = Fals # TODO: response async def configure_node_names(self): - """Deprecated: use ``star._driver.configure_node_names()``.""" + """Deprecated: use ``star.driver.configure_node_names()``.""" return await self.send_command(module="C0", command="AJ") async def set_deck_data(self, data_index: int = 0, data_stream: str = "0"): - """Deprecated: use ``star._driver.set_deck_data()``.""" + """Deprecated: use ``star.driver.set_deck_data()``.""" assert 0 <= data_index <= 9, "data_index must be between 0 and 9" assert len(data_stream) == 12, "data_stream must be 12 chars" @@ -4495,29 +4495,29 @@ async def set_deck_data(self, data_index: int = 0, data_stream: str = "0"): # -------------- 3.3.3 Settings query (stored in EEPROM) -------------- async def request_technical_status_of_assemblies(self): - """Deprecated: use ``star._driver.request_technical_status_of_assemblies()``.""" + """Deprecated: use ``star.driver.request_technical_status_of_assemblies()``.""" # TODO: parse res return await self.send_command(module="C0", command="QT") async def request_installation_data(self): - """Deprecated: use ``star._driver.request_installation_data()``.""" + """Deprecated: use ``star.driver.request_installation_data()``.""" # TODO: parse res return await self.send_command(module="C0", command="RI") async def request_device_serial_number(self) -> str: - """Deprecated: use ``star._driver.request_device_serial_number()``.""" + """Deprecated: use ``star.driver.request_device_serial_number()``.""" return (await self.send_command("C0", "RI", fmt="si####sn&&&&sn&&&&"))["sn"] # type: ignore async def request_download_date(self): - """Deprecated: use ``star._driver.request_download_date()``.""" + """Deprecated: use ``star.driver.request_download_date()``.""" # TODO: parse res return await self.send_command(module="C0", command="RO") async def request_verification_data(self, verification_subject: int = 0): - """Deprecated: use ``star._driver.request_verification_data()``.""" + """Deprecated: use ``star.driver.request_verification_data()``.""" assert 0 <= verification_subject <= 24, "verification_subject must be between 0 and 24" @@ -4525,19 +4525,19 @@ async def request_verification_data(self, verification_subject: int = 0): return await self.send_command(module="C0", command="RO", vo=verification_subject) async def request_additional_timestamp_data(self): - """Deprecated: use ``star._driver.request_additional_timestamp_data()``.""" + """Deprecated: use ``star.driver.request_additional_timestamp_data()``.""" # TODO: parse res return await self.send_command(module="C0", command="RS") async def request_pip_channel_validation_status(self): - """Deprecated: use ``star._driver.request_pip_channel_validation_status()``.""" + """Deprecated: use ``star.driver.request_pip_channel_validation_status()``.""" # TODO: parse res return await self.send_command(module="C0", command="RJ") async def request_xl_channel_validation_status(self): - """Deprecated: use ``star._driver.request_xl_channel_validation_status()``.""" + """Deprecated: use ``star.driver.request_xl_channel_validation_status()``.""" # TODO: parse res return await self.send_command(module="C0", command="UJ") @@ -4636,13 +4636,13 @@ def _parse_drive(byte1: int, byte2: int) -> DriveConfiguration: ) async def request_node_names(self): - """Deprecated: use ``star._driver.request_node_names()``.""" + """Deprecated: use ``star.driver.request_node_names()``.""" # TODO: parse res return await self.send_command(module="C0", command="RK") async def request_deck_data(self): - """Deprecated: use ``star._driver.request_deck_data()``.""" + """Deprecated: use ``star.driver.request_deck_data()``.""" # TODO: parse res return await self.send_command(module="C0", command="VD") @@ -4681,7 +4681,7 @@ async def occupy_and_provide_area_for_external_access( taken_area_size: int = 0, arm_preposition_mode_related_to_taken_areas: int = 0, ): - """Deprecated: use ``star._driver.occupy_and_provide_area_for_external_access()``.""" + """Deprecated: use ``star.driver.occupy_and_provide_area_for_external_access()``.""" assert 0 <= taken_area_identification_number <= 9999, ( "taken_area_identification_number must be between 0 and 9999" @@ -4706,7 +4706,7 @@ async def occupy_and_provide_area_for_external_access( ) async def release_occupied_area(self, taken_area_identification_number: int = 0): - """Deprecated: use ``star._driver.release_occupied_area()``.""" + """Deprecated: use ``star.driver.release_occupied_area()``.""" assert 0 <= taken_area_identification_number <= 999, ( "taken_area_identification_number must be between 0 and 9999" @@ -4719,7 +4719,7 @@ async def release_occupied_area(self, taken_area_identification_number: int = 0) ) async def release_all_occupied_areas(self): - """Deprecated: use ``star._driver.release_all_occupied_areas()``.""" + """Deprecated: use ``star.driver.release_all_occupied_areas()``.""" return await self.send_command(module="C0", command="BC") @@ -4734,12 +4734,12 @@ async def request_right_x_arm_position(self) -> float: return await self._new_right_x_arm.request_position() async def request_maximal_ranges_of_x_drives(self): - """Deprecated: use ``star._driver.request_maximal_ranges_of_x_drives()``.""" + """Deprecated: use ``star.driver.request_maximal_ranges_of_x_drives()``.""" return await self.send_command(module="C0", command="RU") async def request_present_wrap_size_of_installed_arms(self): - """Deprecated: use ``star._driver.request_present_wrap_size_of_installed_arms()``.""" + """Deprecated: use ``star.driver.request_present_wrap_size_of_installed_arms()``.""" return await self.send_command(module="C0", command="UA") @@ -8506,16 +8506,16 @@ async def prepare_iswap_teaching( ): """Deprecated: use ``star.iswap.prepare_teaching()``.""" return await self._new_iswap.prepare_teaching( - x_position=x_position, + x_position=x_position / 10, x_direction=x_direction, - y_position=y_position, + y_position=y_position / 10, y_direction=y_direction, - z_position=z_position, + z_position=z_position / 10, z_direction=z_direction, location=location, - hotel_depth=hotel_depth, + hotel_depth=hotel_depth / 10, grip_direction=grip_direction, - minimum_traverse_height=minimum_traverse_height_at_beginning_of_a_command, + minimum_traverse_height=minimum_traverse_height_at_beginning_of_a_command / 10, collision_control_level=collision_control_level, acceleration_index_high_acc=acceleration_index_high_acc, acceleration_index_low_acc=acceleration_index_low_acc, @@ -8536,14 +8536,14 @@ async def get_logic_iswap_position( ): """Deprecated: use ``star.iswap.get_logic_position()``.""" return await self._new_iswap.get_logic_position( - x_position=x_position, + x_position=x_position / 10, x_direction=x_direction, - y_position=y_position, + y_position=y_position / 10, y_direction=y_direction, - z_position=z_position, + z_position=z_position / 10, z_direction=z_direction, location=location, - hotel_depth=hotel_depth, + hotel_depth=hotel_depth / 10, grip_direction=grip_direction, collision_control_level=collision_control_level, ) diff --git a/pylabrobot/legacy/liquid_handling/liquid_handler.py b/pylabrobot/legacy/liquid_handling/liquid_handler.py index b6766633bf0..b4e47721376 100644 --- a/pylabrobot/legacy/liquid_handling/liquid_handler.py +++ b/pylabrobot/legacy/liquid_handling/liquid_handler.py @@ -21,7 +21,7 @@ Union, ) -from pylabrobot.capabilities.liquid_handling.head96 import Head96Capability +from pylabrobot.capabilities.liquid_handling.head96 import Head96 from pylabrobot.capabilities.liquid_handling.head96_backend import ( Head96Backend as _NewHead96Backend, ) @@ -341,7 +341,7 @@ def __init__( # New capability instances — created during setup() self._lh_cap: Optional[PIP] = None - self._head96_cap: Optional[Head96Capability] = None + self._head96_cap: Optional[Head96] = None # Default offset applied to all 96-head operations. Any offset passed to a 96-head method is # added to this value. @@ -374,7 +374,7 @@ async def setup(self, **backend_kwargs): await self._lh_cap._on_setup() if self.backend.head96_installed: - self._head96_cap = Head96Capability( + self._head96_cap = Head96( backend=_Head96Adapter(self.backend), ) await self._head96_cap._on_setup() diff --git a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py index f84cacabec6..0dac974e55d 100644 --- a/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py +++ b/pylabrobot/legacy/microscopes/molecular_devices/pico/backend.py @@ -56,37 +56,37 @@ def __init__( new_filter_cubes = { pos: _legacy_to_new_imaging_mode(mode) for pos, mode in (filter_cubes or {}).items() } - self._driver = PicoDriver( + self.driver = PicoDriver( host=host, port=port, lock_timeout=lock_timeout, ) self._microscopy = PicoMicroscopyBackend( - self._driver, + self.driver, objectives=new_objectives, filter_cubes=new_filter_cubes, ) @property def door_open(self) -> bool: - return self._driver.door_open + return self.driver.door_open async def setup(self) -> None: - await self._driver.setup() + await self.driver.setup() await self._microscopy._on_setup() async def stop(self) -> None: await self._microscopy._on_stop() - await self._driver.stop() + await self.driver.stop() async def get_configuration(self) -> dict: - return await self._driver.request_configuration() + return await self.driver.request_configuration() async def open_door(self) -> None: - await self._driver.open_door() + await self.driver.open_door() async def close_door(self) -> None: - await self._driver.close_door() + await self.driver.close_door() async def enter_objective_maintenance(self, position: int) -> None: await self._microscopy.enter_objective_maintenance(position) diff --git a/pylabrobot/legacy/only_fans/fan.py b/pylabrobot/legacy/only_fans/fan.py index 6a2dd4a1b5e..fe8afd469f1 100644 --- a/pylabrobot/legacy/only_fans/fan.py +++ b/pylabrobot/legacy/only_fans/fan.py @@ -1,7 +1,7 @@ """Legacy. Use pylabrobot.hamilton.only_fans.HamiltonHepaFan instead.""" from pylabrobot.capabilities.fan_control import FanBackend as _NewFanBackend -from pylabrobot.capabilities.fan_control import FanControlCapability +from pylabrobot.capabilities.fan_control import Fan from pylabrobot.legacy.machines.machine import Machine from .backend import FanBackend @@ -30,7 +30,7 @@ class Fan(Machine): def __init__(self, backend: FanBackend): super().__init__(backend=backend) self._backend: FanBackend = backend - self._cap = FanControlCapability(backend=_FanAdapter(backend)) + self._cap = Fan(backend=_FanAdapter(backend)) async def setup(self, **backend_kwargs): await super().setup(**backend_kwargs) diff --git a/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py b/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py index 5e2795746e9..de3e104ad43 100644 --- a/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py +++ b/pylabrobot/legacy/only_fans/hamilton_hepa_fan_backend.py @@ -8,11 +8,11 @@ class HamiltonHepaFanBackend(FanBackend): """Legacy. Use pylabrobot.hamilton.only_fans.HamiltonHepaFan instead.""" def __init__(self, device_id=None): - self._driver = HamiltonHepaFanDriver(device_id=device_id) - self._fan = HamiltonHepaFanFanBackend(self._driver) + self.driver = HamiltonHepaFanDriver(device_id=device_id) + self._fan = HamiltonHepaFanFanBackend(self.driver) async def setup(self) -> None: - await self._driver.setup() + await self.driver.setup() async def turn_on(self, intensity: int) -> None: await self._fan.turn_on(intensity=intensity) @@ -21,7 +21,7 @@ async def turn_off(self) -> None: await self._fan.turn_off() async def stop(self) -> None: - await self._driver.stop() + await self.driver.stop() class HamiltonHepaFan: diff --git a/pylabrobot/legacy/peeling/xpeel_backend.py b/pylabrobot/legacy/peeling/xpeel_backend.py index b9ec99d98e2..28f4de07cc6 100644 --- a/pylabrobot/legacy/peeling/xpeel_backend.py +++ b/pylabrobot/legacy/peeling/xpeel_backend.py @@ -8,19 +8,19 @@ class XPeelBackend(PeelerBackend): """Legacy. Use pylabrobot.azenta.XPeelDriver and XPeelPeelerBackend instead.""" def __init__(self, port: str, timeout=None): - self._driver = XPeelDriver(port=port, timeout=timeout) - self._peeler = XPeelPeelerBackend(self._driver) + self.driver = XPeelDriver(port=port, timeout=timeout) + self._peeler = XPeelPeelerBackend(self.driver) async def setup(self): - await self._driver.setup() + await self.driver.setup() await self._peeler._on_setup() async def stop(self): await self._peeler._on_stop() - await self._driver.stop() + await self.driver.stop() def serialize(self) -> dict: - return self._driver.serialize() + return self.driver.serialize() async def peel(self, **kwargs): params = XPeelPeelerBackend.PeelParams(**kwargs) if kwargs else None @@ -30,43 +30,43 @@ async def restart(self): return await self._peeler.restart() async def reset(self): - return await self._driver.reset() + return await self.driver.reset() async def get_status(self): - return await self._driver.request_status() + return await self.driver.request_status() async def get_version(self): - return await self._driver.request_version() + return await self.driver.request_version() async def seal_check(self): - return await self._driver.seal_check() + return await self.driver.seal_check() async def get_tape_remaining(self): - return await self._driver.request_tape_remaining() + return await self.driver.request_tape_remaining() async def enable_plate_check(self, enabled=True): - return await self._driver.enable_plate_check(enabled=enabled) + return await self.driver.enable_plate_check(enabled=enabled) async def get_seal_sensor_status(self): - return await self._driver.request_seal_sensor_status() + return await self.driver.request_seal_sensor_status() async def set_seal_threshold_upper(self, value: int): - return await self._driver.set_seal_threshold_upper(value=value) + return await self.driver.set_seal_threshold_upper(value=value) async def set_seal_threshold_lower(self, value: int): - return await self._driver.set_seal_threshold_lower(value=value) + return await self.driver.set_seal_threshold_lower(value=value) async def move_conveyor_out(self): - return await self._driver.move_conveyor_out() + return await self.driver.move_conveyor_out() async def move_conveyor_in(self): - return await self._driver.move_conveyor_in() + return await self.driver.move_conveyor_in() async def move_elevator_down(self): - return await self._driver.move_elevator_down() + return await self.driver.move_elevator_down() async def move_elevator_up(self): - return await self._driver.move_elevator_up() + return await self.driver.move_elevator_up() async def advance_tape(self): - return await self._driver.advance_tape() + return await self.driver.advance_tape() diff --git a/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py b/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py index 6fbb707d83a..7638483bb8b 100644 --- a/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py +++ b/pylabrobot/legacy/plate_reading/bmg_labtech/clario_star_backend.py @@ -24,13 +24,13 @@ class CLARIOstarBackend(PlateReaderBackend): """Legacy. Use pylabrobot.bmg_labtech.CLARIOstar instead.""" def __init__(self, device_id: Optional[str] = None): - self._driver = CLARIOstarDriver(device_id=device_id) - self._absorbance = CLARIOstarAbsorbanceBackend(self._driver) - self._luminescence = CLARIOstarLuminescenceBackend(self._driver) - self._fluorescence = CLARIOstarFluorescenceBackend(self._driver) + self.driver = CLARIOstarDriver(device_id=device_id) + self._absorbance = CLARIOstarAbsorbanceBackend(self.driver) + self._luminescence = CLARIOstarLuminescenceBackend(self.driver) + self._fluorescence = CLARIOstarFluorescenceBackend(self.driver) async def setup(self): - await self._driver.setup() + await self.driver.setup() await self._absorbance._on_setup() await self._luminescence._on_setup() await self._fluorescence._on_setup() @@ -39,16 +39,16 @@ async def stop(self): await self._fluorescence._on_stop() await self._luminescence._on_stop() await self._absorbance._on_stop() - await self._driver.stop() + await self.driver.stop() def serialize(self) -> dict: - return self._driver.serialize() + return self.driver.serialize() async def open(self): - await self._driver.open() + await self.driver.open() async def close(self, plate: Optional[Plate] = None): - await self._driver.close() + await self.driver.close() async def read_luminescence( self, plate: Plate, wells: List[Well], focal_height: float = 13 diff --git a/pylabrobot/legacy/plate_reading/imager.py b/pylabrobot/legacy/plate_reading/imager.py index 9e13074f611..264aca82c52 100644 --- a/pylabrobot/legacy/plate_reading/imager.py +++ b/pylabrobot/legacy/plate_reading/imager.py @@ -1,4 +1,4 @@ -"""Legacy. Use pylabrobot.capabilities.microscopy.MicroscopyCapability instead.""" +"""Legacy. Use pylabrobot.capabilities.microscopy.Microscopy instead.""" from typing import Optional, Tuple, Union, cast @@ -7,7 +7,7 @@ from pylabrobot.capabilities.microscopy import ImagingResult as NewImagingResult from pylabrobot.capabilities.microscopy import ( MicroscopyBackend, - MicroscopyCapability, + Microscopy, evaluate_focus_nvmg_sobel, fraction_overexposed, max_pixel_at_fraction, @@ -121,7 +121,7 @@ def __init__( ) Machine.__init__(self, backend=backend) self._backend: ImagerBackend = backend - self._microscopy = MicroscopyCapability(backend=_ImagerBackendAdapter(backend)) + self._microscopy = Microscopy(backend=_ImagerBackendAdapter(backend)) self._microscopy._setup_finished = True # legacy Machine.setup() handles lifecycle self.register_will_assign_resource_callback(self._will_assign_resource) diff --git a/pylabrobot/legacy/plate_reading/molecular_devices/backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py index c645bec35a6..6236e37cbd9 100644 --- a/pylabrobot/legacy/plate_reading/molecular_devices/backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/backend.py @@ -39,11 +39,11 @@ class MolecularDevicesBackend(PlateReaderBackend): """ def __init__(self, port: str) -> None: - self._driver = self._make_driver(port) - self._absorbance = MolecularDevicesAbsorbanceBackend(self._driver) - self._temperature = MolecularDevicesTemperatureBackend(self._driver) - self._fluorescence = SpectraMaxM5FluorescenceBackend(self._driver) - self._luminescence = SpectraMaxM5LuminescenceBackend(self._driver) + self.driver = self._make_driver(port) + self._absorbance = MolecularDevicesAbsorbanceBackend(self.driver) + self._temperature = MolecularDevicesTemperatureBackend(self.driver) + self._fluorescence = SpectraMaxM5FluorescenceBackend(self.driver) + self._luminescence = SpectraMaxM5LuminescenceBackend(self.driver) def _make_driver(self, port: str): return MolecularDevicesDriver(port=port) @@ -51,22 +51,22 @@ def _make_driver(self, port: str): # -- PlateReaderBackend / MachineBackend interface ----------------------- async def setup(self) -> None: - await self._driver.setup() + await self.driver.setup() async def stop(self) -> None: - await self._driver.stop() + await self.driver.stop() async def open(self) -> None: - await self._driver.open() + await self.driver.open() async def close(self, plate=None) -> None: - await self._driver.close() + await self.driver.close() async def send_command(self, *args, **kwargs): - return await self._driver.send_command(*args, **kwargs) + return await self.driver.send_command(*args, **kwargs) def serialize(self) -> dict: - return dict(self._driver.serialize()) + return dict(self.driver.serialize()) # -- Bridged internals (must be explicit for class-level @patch) --------- @@ -74,7 +74,7 @@ async def _read_now(self): return await self._absorbance._read_now() async def _wait_for_idle(self, **kwargs): - return await self._driver.wait_for_idle(**kwargs) + return await self.driver.wait_for_idle(**kwargs) async def _transfer_data(self, *args, **kwargs): return await self._absorbance._transfer_data(*args, **kwargs) diff --git a/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py b/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py index bd28ce7637c..a65f87e57b9 100644 --- a/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/backend_tests.py @@ -34,16 +34,16 @@ def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): self.backend = MolecularDevicesBackend(port="COM1") - self.backend._driver.io = self.mock_serial + self.backend.driver.io = self.mock_serial self.send_command_mock = patch.object( - self.backend._driver, "send_command", new_callable=AsyncMock + self.backend.driver, "send_command", new_callable=AsyncMock ).start() self.addCleanup(patch.stopall) async def test_setup_stop(self): # un-mock send_command for this test with patch.object( - self.backend._driver, "send_command", wraps=self.backend._driver.send_command + self.backend.driver, "send_command", wraps=self.backend.driver.send_command ) as wrapped_send_command: await self.backend.setup() self.mock_serial.setup.assert_called_once() @@ -683,7 +683,7 @@ def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=MagicMock()): self.backend = MolecularDevicesBackend(port="COM1") self.send_command_mock = patch.object( - self.backend._driver, "send_command", new_callable=AsyncMock + self.backend.driver, "send_command", new_callable=AsyncMock ).start() def test_parse_absorbance_single_wavelength(self): @@ -943,7 +943,7 @@ def setUp(self): with patch("pylabrobot.io.serial.Serial", return_value=self.mock_serial): self.backend = MolecularDevicesBackend(port="/dev/tty01") - self.backend._driver.io = self.mock_serial + self.backend.driver.io = self.mock_serial async def _mock_send_command_response(self, response_str: str): self.mock_serial.readline.side_effect = [response_str.encode() + b">\r\n"] diff --git a/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py index d3e7fc9187d..507a7576e00 100644 --- a/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py +++ b/pylabrobot/legacy/plate_reading/molecular_devices/spectramax_384_plus_backend.py @@ -32,7 +32,7 @@ def _make_driver(self, port: str): def __init__(self, port: str) -> None: super().__init__(port) # Override the absorbance backend with the 384-specific one - self._absorbance = SpectraMax384PlusAbsorbanceBackend(self._driver) + self._absorbance = SpectraMax384PlusAbsorbanceBackend(self.driver) async def read_fluorescence( # type: ignore[override] self, diff --git a/pylabrobot/legacy/plate_reading/plate_reader.py b/pylabrobot/legacy/plate_reading/plate_reader.py index c0796f53e68..afe8cfd66e4 100644 --- a/pylabrobot/legacy/plate_reading/plate_reader.py +++ b/pylabrobot/legacy/plate_reading/plate_reader.py @@ -1,17 +1,17 @@ import logging from typing import Dict, List, Optional, cast -from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance from pylabrobot.capabilities.plate_reading.absorbance.backend import ( AbsorbanceBackend as _NewAbsorbanceBackend, ) from pylabrobot.capabilities.plate_reading.absorbance.standard import AbsorbanceResult -from pylabrobot.capabilities.plate_reading.fluorescence import FluorescenceCapability +from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence from pylabrobot.capabilities.plate_reading.fluorescence.backend import ( FluorescenceBackend as _NewFluorescenceBackend, ) from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult -from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability +from pylabrobot.capabilities.plate_reading.luminescence import Luminescence from pylabrobot.capabilities.plate_reading.luminescence.backend import ( LuminescenceBackend as _NewLuminescenceBackend, ) @@ -161,9 +161,9 @@ def __init__( Machine.__init__(self, backend=backend) self.backend: PlateReaderBackend = backend # fix type - self._absorbance_cap = AbsorbanceCapability(backend=_AbsorbanceAdapter(backend)) - self._luminescence_cap = LuminescenceCapability(backend=_LuminescenceAdapter(backend)) - self._fluorescence_cap = FluorescenceCapability(backend=_FluorescenceAdapter(backend)) + self._absorbance_cap = Absorbance(backend=_AbsorbanceAdapter(backend)) + self._luminescence_cap = Luminescence(backend=_LuminescenceAdapter(backend)) + self._fluorescence_cap = Fluorescence(backend=_FluorescenceAdapter(backend)) async def setup(self, **backend_kwargs): await super().setup(**backend_kwargs) diff --git a/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py index 01d75aebbc5..997a0acf1c4 100644 --- a/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py +++ b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_backend.py @@ -10,38 +10,38 @@ class AgrowPumpArrayBackend(PumpArrayBackend): """Legacy. Use pylabrobot.agrowpumps.AgrowDosePumpArray instead.""" def __init__(self, port: str, address: Union[int, str]): - self._driver = AgrowDriver(port=port, address=address) + self.driver = AgrowDriver(port=port, address=address) self._backends: List[AgrowChannelBackend] = [] @property def port(self): - return self._driver.port + return self.driver.port @property def address(self): - return self._driver.address + return self.driver.address @property def modbus(self): - return self._driver.modbus + return self.driver.modbus @property def num_channels(self) -> int: - return self._driver.num_channels + return self.driver.num_channels @property def pump_index_to_address(self) -> Dict[int, int]: - return self._driver.pump_index_to_address + return self.driver.pump_index_to_address async def setup(self): - await self._driver.setup() + await self.driver.setup() self._backends = [ - AgrowChannelBackend(self._driver, ch) for ch in range(self._driver.num_channels) + AgrowChannelBackend(self.driver, ch) for ch in range(self.driver.num_channels) ] async def stop(self): await self.halt() - await self._driver.stop() + await self.driver.stop() def serialize(self): return { diff --git a/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py index 546506a2b09..00dd9f43d17 100644 --- a/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py +++ b/pylabrobot/legacy/pumps/agrowpumps/agrowdosepump_tests.py @@ -41,9 +41,9 @@ async def asyncSetUp(self): self.agrow_backend = AgrowPumpArrayBackend(port="simulated", address=1) async def _mock_setup_modbus(): - self.agrow_backend._driver._modbus = SimulatedModbusClient() + self.agrow_backend.driver._modbus = SimulatedModbusClient() - with patch.object(self.agrow_backend._driver, "_setup_modbus", _mock_setup_modbus): + with patch.object(self.agrow_backend.driver, "_setup_modbus", _mock_setup_modbus): self.pump_array = PumpArray(backend=self.agrow_backend, calibration=None) await self.pump_array.setup() diff --git a/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py b/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py index c7ca690b254..87779c56756 100644 --- a/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py +++ b/pylabrobot/legacy/pumps/cole_parmer/masterflex_backend.py @@ -9,28 +9,28 @@ class MasterflexBackend(PumpBackend): """Legacy. Use pylabrobot.cole_parmer.MasterflexBackend instead.""" def __init__(self, com_port: str): - self._driver = MasterflexDriver(com_port=com_port) - self._backend = _NewBackend(self._driver) + self.driver = MasterflexDriver(com_port=com_port) + self._backend = _NewBackend(self.driver) @property def io(self): - return self._driver.io + return self.driver.io @io.setter def io(self, value): - self._driver.io = value + self.driver.io = value async def setup(self): - await self._driver.setup() + await self.driver.setup() async def stop(self): - await self._driver.stop() + await self.driver.stop() def serialize(self): - return {"type": self.__class__.__name__, "com_port": self._driver.com_port} + return {"type": self.__class__.__name__, "com_port": self.driver.com_port} async def send_command(self, command: str): - return await self._driver.send_command(command) + return await self.driver.send_command(command) async def run_revolutions(self, num_revolutions: float): await self._backend.run_revolutions(num_revolutions) diff --git a/pylabrobot/legacy/pumps/pump.py b/pylabrobot/legacy/pumps/pump.py index 1f28c81765e..63537126b90 100644 --- a/pylabrobot/legacy/pumps/pump.py +++ b/pylabrobot/legacy/pumps/pump.py @@ -1,7 +1,7 @@ from typing import Optional, Union from pylabrobot.capabilities.pumping.backend import PumpBackend as _NewPumpBackend -from pylabrobot.capabilities.pumping.pumping import PumpingCapability +from pylabrobot.capabilities.pumping.pumping import Pump from pylabrobot.legacy.machines.machine import Machine from .backend import PumpBackend @@ -37,7 +37,7 @@ def __init__( if calibration is not None and len(calibration) != 1: raise ValueError("Calibration may only have a single item for this pump") self.calibration = calibration - self._pumping = PumpingCapability(backend=_PumpAdapter(backend), calibration=calibration) + self._pumping = Pump(backend=_PumpAdapter(backend), calibration=calibration) async def setup(self, **backend_kwargs): await super().setup(**backend_kwargs) diff --git a/pylabrobot/legacy/pumps/pumparray.py b/pylabrobot/legacy/pumps/pumparray.py index af5d7ff1034..213a1300d9c 100644 --- a/pylabrobot/legacy/pumps/pumparray.py +++ b/pylabrobot/legacy/pumps/pumparray.py @@ -2,7 +2,7 @@ from typing import List, Optional, Union from pylabrobot.capabilities.pumping.backend import PumpBackend as _NewPumpBackend -from pylabrobot.capabilities.pumping.pumping import PumpingCapability +from pylabrobot.capabilities.pumping.pumping import Pump from pylabrobot.legacy.machines.machine import Machine from pylabrobot.legacy.pumps.backend import PumpArrayBackend from pylabrobot.legacy.pumps.calibration import PumpCalibration @@ -39,7 +39,7 @@ def __init__( super().__init__(backend=backend) self.backend: PumpArrayBackend = backend self.calibration = calibration - self._pumps: List[PumpingCapability] = [] + self._pumps: List[Pump] = [] @property def num_channels(self) -> int: @@ -48,7 +48,7 @@ def num_channels(self) -> int: async def setup(self, **backend_kwargs): await super().setup(**backend_kwargs) self._pumps = [ - PumpingCapability(backend=_ChannelAdapter(self.backend, ch)) + Pump(backend=_ChannelAdapter(self.backend, ch)) for ch in range(self.num_channels) ] for p in self._pumps: diff --git a/pylabrobot/legacy/scales/mettler_toledo_backend.py b/pylabrobot/legacy/scales/mettler_toledo_backend.py index 8ed2aa9dc47..74b1aa5226b 100644 --- a/pylabrobot/legacy/scales/mettler_toledo_backend.py +++ b/pylabrobot/legacy/scales/mettler_toledo_backend.py @@ -19,19 +19,19 @@ class MettlerToledoWXS205SDUBackend(ScaleBackend): """Legacy. Use MettlerToledoWXS205SDUDriver + MettlerToledoWXS205SDUScaleBackend instead.""" def __init__(self, port: Optional[str] = None, vid: int = 0x0403, pid: int = 0x6001): - self._driver = MettlerToledoWXS205SDUDriver(port=port, vid=vid, pid=pid) - self._scale = MettlerToledoWXS205SDUScaleBackend(self._driver) + self.driver = MettlerToledoWXS205SDUDriver(port=port, vid=vid, pid=pid) + self._scale = MettlerToledoWXS205SDUScaleBackend(self.driver) async def setup(self) -> None: - await self._driver.setup() + await self.driver.setup() await self._scale._on_setup() async def stop(self) -> None: await self._scale._on_stop() - await self._driver.stop() + await self.driver.stop() def serialize(self) -> dict: - return self._driver.serialize() + return self.driver.serialize() async def zero(self, timeout: Union[Literal["stable"], float, int] = "stable"): return await self._scale.zero(timeout=timeout) @@ -51,7 +51,7 @@ async def get_weight(self, timeout: Union[Literal["stable"], float, int] = "stab return await self._scale.read_weight(timeout=timeout) async def send_command(self, command: str, timeout: int = 60): - return await self._driver.send_command(command=command, timeout=timeout) + return await self.driver.send_command(command=command, timeout=timeout) async def request_serial_number(self) -> str: return await self._scale.request_serial_number() @@ -69,10 +69,10 @@ async def read_weight_value_immediately(self) -> float: return await self._scale.read_weight_value_immediately() async def set_display_text(self, text: str): - return await self._driver.set_display_text(text=text) + return await self.driver.set_display_text(text=text) async def set_weight_display(self): - return await self._driver.set_weight_display() + return await self.driver.set_weight_display() # Deprecated aliases diff --git a/pylabrobot/legacy/sealing/a4s_backend.py b/pylabrobot/legacy/sealing/a4s_backend.py index 628b10c4fb2..2c11b50fbba 100644 --- a/pylabrobot/legacy/sealing/a4s_backend.py +++ b/pylabrobot/legacy/sealing/a4s_backend.py @@ -8,22 +8,22 @@ class A4SBackend(SealerBackend): """Legacy. Use pylabrobot.azenta.A4SDriver / A4SSealerBackend / A4STemperatureBackend instead.""" def __init__(self, port: str, timeout: int = 20): - self._driver = A4SDriver(port=port, timeout=timeout) - self._sealer = A4SSealerBackend(self._driver) - self._temperature = A4STemperatureBackend(self._driver) + self.driver = A4SDriver(port=port, timeout=timeout) + self._sealer = A4SSealerBackend(self.driver) + self._temperature = A4STemperatureBackend(self.driver) async def setup(self): - await self._driver.setup() + await self.driver.setup() await self._sealer._on_setup() await self._temperature._on_setup() async def stop(self): await self._temperature._on_stop() await self._sealer._on_stop() - await self._driver.stop() + await self.driver.stop() def serialize(self) -> dict: - return self._driver.serialize() + return self.driver.serialize() async def seal(self, temperature: int, duration: float): await self._sealer.seal(temperature=temperature, duration=duration) @@ -41,16 +41,16 @@ async def get_temperature(self) -> float: return await self._temperature.request_current_temperature() async def set_heater(self, on: bool): - await self._driver.set_heater(on=on) + await self.driver.set_heater(on=on) async def system_reset(self): - await self._driver.system_reset() + await self.driver.system_reset() async def set_time(self, seconds: float): - await self._driver.set_time(seconds=seconds) + await self.driver.set_time(seconds=seconds) async def get_remaining_time(self) -> int: - return await self._driver.request_remaining_time() + return await self.driver.request_remaining_time() async def get_status(self): - return await self._driver.request_status() + return await self.driver.request_status() diff --git a/pylabrobot/legacy/shaking/shaker.py b/pylabrobot/legacy/shaking/shaker.py index 24387b92a48..13be93fdc65 100644 --- a/pylabrobot/legacy/shaking/shaker.py +++ b/pylabrobot/legacy/shaking/shaker.py @@ -1,7 +1,7 @@ from typing import Optional from pylabrobot.capabilities.shaking import ShakerBackend as _NewShakerBackend -from pylabrobot.capabilities.shaking import ShakingCapability +from pylabrobot.capabilities.shaking import Shaker from pylabrobot.legacy.machines.machine import Machine from pylabrobot.resources import Coordinate, ResourceHolder @@ -36,7 +36,7 @@ async def unlock_plate(self): class Shaker(ResourceHolder, Machine): - """Legacy. Use a vendor-specific machine with ShakingCapability instead.""" + """Legacy. Use a vendor-specific machine with Shaker instead.""" def __init__( self, @@ -61,7 +61,7 @@ def __init__( ) Machine.__init__(self, backend=backend) self.backend: ShakerBackend = backend - self._shaking_cap = ShakingCapability(backend=_ShakingAdapter(backend)) + self._shaking_cap = Shaker(backend=_ShakingAdapter(backend)) async def setup(self, **backend_kwargs): await super().setup(**backend_kwargs) diff --git a/pylabrobot/legacy/storage/inheco/scila/scila_backend.py b/pylabrobot/legacy/storage/inheco/scila/scila_backend.py index 8283f1db379..3e0049222b8 100644 --- a/pylabrobot/legacy/storage/inheco/scila/scila_backend.py +++ b/pylabrobot/legacy/storage/inheco/scila/scila_backend.py @@ -12,26 +12,26 @@ class SCILABackend(MachineBackend): """Legacy. Use pylabrobot.inheco.scila.SCILADriver and SCILATemperatureBackend instead.""" def __init__(self, scila_ip: str, client_ip: Optional[str] = None) -> None: - self._driver = SCILADriver(scila_ip=scila_ip, client_ip=client_ip) - self._temp = SCILATemperatureBackend(driver=self._driver) + self.driver = SCILADriver(scila_ip=scila_ip, client_ip=client_ip) + self._temp = SCILATemperatureBackend(driver=self.driver) @property def _sila_interface(self): - return self._driver._sila_interface + return self.driver._sila_interface async def setup(self) -> None: - await self._driver.setup() + await self.driver.setup() await self._temp._on_setup() async def stop(self) -> None: await self._temp._on_stop() - await self._driver.stop() + await self.driver.stop() async def request_status(self) -> str: - return await self._driver.request_status() + return await self.driver.request_status() async def request_liquid_level(self) -> str: - return await self._driver.request_liquid_level() + return await self.driver.request_liquid_level() async def request_temperature_information(self) -> dict[str, Any]: return await self._temp.request_temperature_information() @@ -46,22 +46,22 @@ async def is_temperature_control_enabled(self) -> bool: return await self._temp.is_temperature_control_enabled() async def open(self, drawer_id: int) -> None: - await self._driver.open(drawer_id=drawer_id) + await self.driver.open(drawer_id=drawer_id) async def close(self, drawer_id: int) -> None: - await self._driver.close(drawer_id=drawer_id) + await self.driver.close(drawer_id=drawer_id) async def request_drawer_statuses(self) -> Dict[int, DrawerStatus]: - return await self._driver.request_drawer_statuses() + return await self.driver.request_drawer_statuses() async def request_drawer_status(self, drawer_id: int) -> DrawerStatus: - return await self._driver.request_drawer_status(drawer_id=drawer_id) + return await self.driver.request_drawer_status(drawer_id=drawer_id) async def request_co2_flow_status(self) -> str: - return await self._driver.request_co2_flow_status() + return await self.driver.request_co2_flow_status() async def request_valve_status(self) -> dict[str, str]: - return await self._driver.request_valve_status() + return await self.driver.request_valve_status() async def start_temperature_control(self, temperature: float) -> None: await self._temp.set_temperature(temperature=temperature) @@ -70,7 +70,7 @@ async def stop_temperature_control(self) -> None: await self._temp.deactivate() def serialize(self) -> dict[str, Any]: - return self._driver.serialize() + return self.driver.serialize() @classmethod def deserialize(cls, data: dict[str, Any]) -> "SCILABackend": diff --git a/pylabrobot/legacy/temperature_controlling/opentrons_backend.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py index 1983e2f4471..92dcfffa8be 100644 --- a/pylabrobot/legacy/temperature_controlling/opentrons_backend.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend.py @@ -19,20 +19,20 @@ def supports_active_cooling(self) -> bool: return self._backend.supports_active_cooling def __init__(self, opentrons_id: str): - self._driver = OpentronsTemperatureModuleDriver(opentrons_id=opentrons_id) - self._backend = _NewBackend(driver=self._driver) + self.driver = OpentronsTemperatureModuleDriver(opentrons_id=opentrons_id) + self._backend = _NewBackend(driver=self.driver) self.opentrons_id = opentrons_id async def setup(self): - await self._driver.setup() + await self.driver.setup() await self._backend._on_setup() async def stop(self): await self._backend._on_stop() - await self._driver.stop() + await self.driver.stop() def serialize(self) -> dict: - return self._driver.serialize() + return self.driver.serialize() async def set_temperature(self, temperature: float): await self._backend.set_temperature(temperature) diff --git a/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py index d4170034fa4..cbabb8090d4 100644 --- a/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/legacy/temperature_controlling/opentrons_backend_usb.py @@ -19,20 +19,20 @@ def supports_active_cooling(self) -> bool: return self._backend.supports_active_cooling def __init__(self, port: str): - self._driver = OpentronsTemperatureModuleUSBDriver(port=port) - self._backend = _NewBackend(driver=self._driver) + self.driver = OpentronsTemperatureModuleUSBDriver(port=port) + self._backend = _NewBackend(driver=self.driver) self.port = port async def setup(self): - await self._driver.setup() + await self.driver.setup() await self._backend._on_setup() async def stop(self): await self._backend._on_stop() - await self._driver.stop() + await self.driver.stop() def serialize(self) -> dict: - return self._driver.serialize() + return self.driver.serialize() async def set_temperature(self, temperature: float): await self._backend.set_temperature(temperature) diff --git a/pylabrobot/legacy/temperature_controlling/temperature_controller.py b/pylabrobot/legacy/temperature_controlling/temperature_controller.py index f09000b0cbb..ccc6d63f474 100644 --- a/pylabrobot/legacy/temperature_controlling/temperature_controller.py +++ b/pylabrobot/legacy/temperature_controlling/temperature_controller.py @@ -1,6 +1,6 @@ from typing import Optional -from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.capabilities.temperature_controlling import TemperatureController from pylabrobot.capabilities.temperature_controlling import ( TemperatureControllerBackend as _NewTCBackend, ) @@ -60,7 +60,7 @@ def __init__( ) Machine.__init__(self, backend=backend) self.backend: TemperatureControllerBackend = backend - self._tc_cap = TemperatureControlCapability(backend=_TemperatureControlAdapter(backend)) + self._tc_cap = TemperatureController(backend=_TemperatureControlAdapter(backend)) @property def target_temperature(self) -> Optional[float]: diff --git a/pylabrobot/legacy/thermocycling/opentrons_backend_usb.py b/pylabrobot/legacy/thermocycling/opentrons_backend_usb.py index 3c5f809205d..7b51512f593 100644 --- a/pylabrobot/legacy/thermocycling/opentrons_backend_usb.py +++ b/pylabrobot/legacy/thermocycling/opentrons_backend_usb.py @@ -97,7 +97,7 @@ def __init__(self): if not USE_OPENTRONS_DRIVER: raise RuntimeError("Opentrons thermocycler driver not available") from _import_error - self._driver: Optional[AbstractThermocyclerDriver] = None + self.driver: Optional[AbstractThermocyclerDriver] = None self._current_protocol: Optional[Protocol] = None self._loop: Optional[asyncio.AbstractEventLoop] = None @@ -120,9 +120,9 @@ async def _execute_cycle_step( volume: Optional[float] = None, ) -> None: """Execute a single thermocycler step (uses shared utility).""" - assert self._driver is not None + assert self.driver is not None await execute_cycle_step( - driver=self._driver, + driver=self.driver, temperature=temperature, hold_time_seconds=hold_time_seconds, ramp_rate=ramp_rate, @@ -136,7 +136,7 @@ async def _execute_cycles( volume: Optional[float], ) -> None: """Execute cycles of temperature steps directly from protocol (with cycle tracking).""" - assert self._driver is not None + assert self.driver is not None self._total_cycle_count = repetitions self._total_step_count = 0 for stage in protocol.stages: @@ -159,7 +159,7 @@ async def _execute_cycles( ramp_rate = step.rate if step.rate is not None else None self._current_step_index += 1 await execute_cycle_step( - driver=self._driver, + driver=self.driver, temperature=temperature, hold_time_seconds=hold_time, ramp_rate=ramp_rate, @@ -208,32 +208,32 @@ async def setup(self, port: Optional[str] = None): else: port = opentrons_ports[0].device - self._driver = await ThermocyclerDriverFactory.create(port, self._loop) + self.driver = await ThermocyclerDriverFactory.create(port, self._loop) async def stop(self): - if self._driver is not None: + if self.driver is not None: await self.deactivate_block() await self.deactivate_lid() - await self._driver.disconnect() - self._driver = None + await self.driver.disconnect() + self.driver = None async def open_lid(self): - assert self._driver is not None - await self._driver.open_lid() + assert self.driver is not None + await self.driver.open_lid() async def close_lid(self): - assert self._driver is not None - await self._driver.close_lid() + assert self.driver is not None + await self.driver.close_lid() async def lift_plate(self): """Lift the thermocycler plate to un-stick and robustly pick up with robot arm.""" - assert self._driver is not None - await self._driver.lift_plate() + assert self.driver is not None + await self.driver.lift_plate() async def jog_lid(self, angle: float): """Jog the lid to a specific angle position.""" - assert self._driver is not None - await self._driver.jog_lid(angle) + assert self.driver is not None + await self.driver.jog_lid(angle) async def set_block_temperature(self, temperature: List[float]): """Set block temperature in °C. Only single unique temperature supported. @@ -244,8 +244,8 @@ async def set_block_temperature(self, temperature: List[float]): f"Opentrons thermocycler only supports a single unique block temperature, got {set(temperature)}" ) temp_value = temperature[0] - assert self._driver is not None - await self._driver.set_plate_temperature(temp_value) + assert self.driver is not None + await self.driver.set_plate_temperature(temp_value) async def set_lid_temperature(self, temperature: List[float]): """Set lid temperature in °C. Only single unique temperature supported.""" @@ -254,68 +254,68 @@ async def set_lid_temperature(self, temperature: List[float]): f"Opentrons thermocycler only supports a single unique lid temperature, got {set(temperature)}" ) temp_value = temperature[0] - assert self._driver is not None - await self._driver.set_lid_temperature(temp_value) + assert self.driver is not None + await self.driver.set_lid_temperature(temp_value) async def set_ramp_rate(self, ramp_rate: float): """Set the temperature ramp rate in °C/minute.""" - assert self._driver is not None - await self._driver.set_ramp_rate(ramp_rate) + assert self.driver is not None + await self.driver.set_ramp_rate(ramp_rate) async def deactivate_block(self): """Deactivate the block heater.""" - assert self._driver is not None - await self._driver.deactivate_block() + assert self.driver is not None + await self.driver.deactivate_block() async def deactivate_lid(self): """Deactivate the lid heater.""" - assert self._driver is not None - await self._driver.deactivate_lid() + assert self.driver is not None + await self.driver.deactivate_lid() async def get_device_info(self) -> dict: - assert self._driver is not None - return await self._driver.get_device_info() # type: ignore + assert self.driver is not None + return await self.driver.get_device_info() # type: ignore async def get_block_current_temperature(self) -> List[float]: - assert self._driver is not None - plate_temp = await self._driver.get_plate_temperature() + assert self.driver is not None + plate_temp = await self.driver.get_plate_temperature() return [plate_temp.current] async def get_block_target_temperature(self) -> List[float]: - assert self._driver is not None - plate_temp = await self._driver.get_plate_temperature() + assert self.driver is not None + plate_temp = await self.driver.get_plate_temperature() if plate_temp.target is not None: return [plate_temp.target] raise RuntimeError("Block target temperature is not set.") async def get_lid_current_temperature(self) -> List[float]: - assert self._driver is not None - lid_temp = await self._driver.get_lid_temperature() + assert self.driver is not None + lid_temp = await self.driver.get_lid_temperature() return [lid_temp.current] async def get_lid_target_temperature(self) -> List[float]: - assert self._driver is not None - lid_temp = await self._driver.get_lid_temperature() + assert self.driver is not None + lid_temp = await self.driver.get_lid_temperature() if lid_temp.target is not None: return [lid_temp.target] raise RuntimeError("Lid target temperature is not set.") async def get_lid_open(self) -> bool: """Return True if the lid is open.""" - assert self._driver is not None - lid_status = await self._driver.get_lid_status() + assert self.driver is not None + lid_status = await self.driver.get_lid_status() return lid_status == ThermocyclerLidStatus.OPEN # type: ignore async def get_lid_status(self) -> LidStatus: - assert self._driver is not None - lid_temp = await self._driver.get_lid_temperature() + assert self.driver is not None + lid_temp = await self.driver.get_lid_temperature() if lid_temp.target is not None and abs(lid_temp.current - lid_temp.target) < 1.0: return LidStatus.HOLDING_AT_TARGET return LidStatus.IDLE async def get_block_status(self) -> BlockStatus: - assert self._driver is not None - plate_temp = await self._driver.get_plate_temperature() + assert self.driver is not None + plate_temp = await self.driver.get_plate_temperature() if plate_temp.target is not None and abs(plate_temp.current - plate_temp.target) < 1.0: return BlockStatus.HOLDING_AT_TARGET return BlockStatus.IDLE diff --git a/pylabrobot/legacy/tilting/tilter.py b/pylabrobot/legacy/tilting/tilter.py index 2eadf6c7fb9..e5f26d27334 100644 --- a/pylabrobot/legacy/tilting/tilter.py +++ b/pylabrobot/legacy/tilting/tilter.py @@ -4,7 +4,7 @@ from typing import List, Optional from pylabrobot.capabilities.tilting import TilterBackend as _NewTilterBackend -from pylabrobot.capabilities.tilting import TiltingCapability +from pylabrobot.capabilities.tilting import Tilter from pylabrobot.legacy.machines import Machine from pylabrobot.legacy.tilting.tilter_backend import TilterBackend from pylabrobot.resources import Coordinate, Plate @@ -55,7 +55,7 @@ def __init__( self.backend: TilterBackend = backend self._hinge_coordinate = hinge_coordinate - self.tilting = TiltingCapability(backend=_TiltingAdapter(backend)) + self.tilting = Tilter(backend=_TiltingAdapter(backend)) self._capabilities = [self.tilting] @property diff --git a/pylabrobot/liconic/liconic.py b/pylabrobot/liconic/liconic.py index 6409e9c51ae..5b601460e09 100644 --- a/pylabrobot/liconic/liconic.py +++ b/pylabrobot/liconic/liconic.py @@ -1,11 +1,11 @@ import random from typing import List, Literal, Optional, Union, cast -from pylabrobot.capabilities.automated_retrieval import AutomatedRetrievalCapability -from pylabrobot.capabilities.barcode_scanning import BarcodeScanningCapability -from pylabrobot.capabilities.humidity_controlling import HumidityControlCapability -from pylabrobot.capabilities.shaking import ShakingCapability -from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval +from pylabrobot.capabilities.barcode_scanning import BarcodeScanner +from pylabrobot.capabilities.humidity_controlling import HumidityController +from pylabrobot.capabilities.shaking import Shaker +from pylabrobot.capabilities.temperature_controlling import TemperatureController from pylabrobot.device import Device from pylabrobot.resources import ( Coordinate, @@ -33,7 +33,7 @@ def __init__( racks: List[PlateCarrier], loading_tray_location: Coordinate, has_shaker: bool = False, - barcode_scanner: Optional[BarcodeScanningCapability] = None, + barcode_scanner: Optional[BarcodeScanner] = None, size_x: float = 0, size_y: float = 0, size_z: float = 0, @@ -56,7 +56,7 @@ def __init__( model=model, ) Device.__init__(self, driver=backend) - self._driver: LiconicBackend = backend + self.driver: LiconicBackend = backend self.loading_tray = PlateHolder( name=f"{name}_tray", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0 @@ -67,16 +67,16 @@ def __init__( for rack in self._racks: self.assign_child_resource(rack, location=None) - self.retrieval = AutomatedRetrievalCapability(backend=backend) + self.retrieval = AutomatedRetrieval(backend=backend) self.tc = ( - TemperatureControlCapability(backend=backend) + TemperatureController(backend=backend) if liconic_model.has_temperature_control else None ) self.humidity_controller = ( - HumidityControlCapability(backend=backend) if liconic_model.has_humidity_control else None + HumidityController(backend=backend) if liconic_model.has_humidity_control else None ) - self.shaker = ShakingCapability(backend=backend) if has_shaker else None + self.shaker = Shaker(backend=backend) if has_shaker else None self.barcode_scanner = barcode_scanner self._capabilities = [ @@ -99,7 +99,7 @@ async def setup(self, **backend_kwargs): if self.barcode_scanner is not None: await self.barcode_scanner.backend._on_setup() await super().setup() - await self._driver.set_racks(self._racks) + await self.driver.set_racks(self._racks) async def stop(self): await super().stop() diff --git a/pylabrobot/mettler_toledo/mettler_toledo.py b/pylabrobot/mettler_toledo/mettler_toledo.py index 53c209c9b73..6642e7cd357 100644 --- a/pylabrobot/mettler_toledo/mettler_toledo.py +++ b/pylabrobot/mettler_toledo/mettler_toledo.py @@ -286,19 +286,19 @@ class MettlerToledoWXS205SDUScaleBackend(ScaleBackend): """ def __init__(self, driver: MettlerToledoWXS205SDUDriver): - self._driver = driver + self.driver = driver self.serial_number: Optional[str] = None async def _on_setup(self) -> None: """Initialize scale after driver connects: set output unit to grams and read serial.""" - await self._driver.send_command("M21 0 0") + await self.driver.send_command("M21 0 0") self.serial_number = await self.request_serial_number() # === Public high-level API === async def request_serial_number(self) -> str: """Get the serial number of the scale. (MEM-READ command)""" - response = await self._driver.send_command("I4") + response = await self.driver.send_command("I4") serial_number = response[2] serial_number = serial_number.replace('"', "") return serial_number @@ -307,18 +307,18 @@ async def request_serial_number(self) -> str: async def zero_immediately(self) -> MettlerToledoResponse: """Zero the scale immediately. (ACTION command)""" - return await self._driver.send_command("ZI") + return await self.driver.send_command("ZI") async def zero_stable(self) -> MettlerToledoResponse: """Zero the scale when the weight is stable. (ACTION command)""" - return await self._driver.send_command("Z") + return await self.driver.send_command("Z") async def zero_timeout(self, timeout: float) -> MettlerToledoResponse: """Zero the scale after a given timeout. (ACTION command)""" # For some reason, this will always return a syntax error (ES), even though it should be allowed # according to the docs. timeout = int(timeout * 1000) - return await self._driver.send_command(f"ZC {timeout}") + return await self.driver.send_command(f"ZC {timeout}") async def zero( self, timeout: Union[Literal["stable"], float, int] = "stable" @@ -349,18 +349,18 @@ async def zero( async def tare_stable(self) -> MettlerToledoResponse: """Tare the scale when the weight is stable. (ACTION command)""" - return await self._driver.send_command("T") + return await self.driver.send_command("T") async def tare_immediately(self) -> MettlerToledoResponse: """Tare the scale immediately. (ACTION command)""" - return await self._driver.send_command("TI") + return await self.driver.send_command("TI") async def tare_timeout(self, timeout: float) -> MettlerToledoResponse: """Tare the scale after a given timeout. (ACTION command)""" # For some reason, this will always return a syntax error (ES), even though it should be allowed # according to the docs. timeout = int(timeout * 1000) # convert to milliseconds - return await self._driver.send_command(f"TC {timeout}") + return await self.driver.send_command(f"TC {timeout}") async def tare( self, timeout: Union[Literal["stable"], float, int] = "stable" @@ -394,7 +394,7 @@ async def request_tare_weight(self) -> float: "Use TA to query the current tare value or preset a known tare value." """ - response = await self._driver.send_command("TA") + response = await self.driver.send_command("TA") tare = float(response[2]) unit = response[3] assert unit == "g" # this is the format we expect @@ -402,7 +402,7 @@ async def request_tare_weight(self) -> float: async def clear_tare(self) -> MettlerToledoResponse: """TAC - Clear tare weight value (MEM-WRITE command)""" - return await self._driver.send_command("TAC") + return await self.driver.send_command("TAC") async def read_stable_weight(self) -> float: """Read a stable weight value from the scale. (MEASUREMENT command) @@ -415,7 +415,7 @@ async def read_stable_weight(self) -> float: doors to achieve a stable weight." """ - response = await self._driver.send_command("S") + response = await self.driver.send_command("S") weight = float(response[2]) unit = response[3] assert unit == "g" # this is the format we expect @@ -431,7 +431,7 @@ async def read_dynamic_weight(self, timeout: float) -> float: timeout = int(timeout * 1000) # convert to milliseconds - response = await self._driver.send_command(f"SC {timeout}") + response = await self.driver.send_command(f"SC {timeout}") weight = float(response[2]) unit = response[3] assert unit == "g" # this is the format we expect @@ -444,7 +444,7 @@ async def read_weight_value_immediately(self) -> float: balance to the connected communication partner via the interface." """ - response = await self._driver.send_command("SI") + response = await self.driver.send_command("SI") weight = float(response[2]) assert response[3] == "g" # this is the format we expect return weight diff --git a/pylabrobot/molecular_devices/imageXpress/pico/backend.py b/pylabrobot/molecular_devices/imageXpress/pico/backend.py index a5c4fc8dc4c..1896da0c2bc 100644 --- a/pylabrobot/molecular_devices/imageXpress/pico/backend.py +++ b/pylabrobot/molecular_devices/imageXpress/pico/backend.py @@ -510,7 +510,7 @@ def __init__( objectives: Optional[Dict[int, Objective]] = None, filter_cubes: Optional[Dict[int, ImagingMode]] = None, ): - self._driver = driver + self.driver = driver for pos, obj in (objectives or {}).items(): if obj not in _OBJECTIVE_MAP: @@ -548,24 +548,24 @@ async def _on_setup(self): # -- objectives & filter cubes -- async def _request_installed_objectives(self) -> List[dict]: - raw = await self._driver._call(_OBJ_SVC, "Get_InstalledObjectives", b"") + raw = await self.driver._call(_OBJ_SVC, "Get_InstalledObjectives", b"") data: dict = json.loads(decode_sila_string_response(raw)) return list(data.get("objectivesData", [])) async def _request_installed_filter_cubes(self) -> List[dict]: - raw = await self._driver._call(_FC_SVC, "Get_InstalledFilterCubes", b"") + raw = await self.driver._call(_FC_SVC, "Get_InstalledFilterCubes", b"") data: dict = json.loads(decode_sila_string_response(raw)) return list(data.get("filterCubesData", [])) async def request_available_objectives(self, position: int) -> List[dict]: params = json.dumps({"Index": position}) req = length_delimited(1, sila_string(params)) - raw = await self._driver._call(_OBJ_SVC, "GetAvailableObjectivesForPosition", req, True) + raw = await self.driver._call(_OBJ_SVC, "GetAvailableObjectivesForPosition", req, True) data: dict = json.loads(decode_sila_string_response(raw)) return list(data.get("objectives", data.get("Objectives", []))) async def request_available_filter_cubes(self) -> List[dict]: - raw = await self._driver._call(_FC_SVC, "Get_CompatibleFilterCubes", b"") + raw = await self.driver._call(_FC_SVC, "Get_CompatibleFilterCubes", b"") data: dict = json.loads(decode_sila_string_response(raw)) return list(data.get("filterCubes", data.get("FilterCubes", []))) @@ -579,7 +579,7 @@ async def change_objective(self, position: int, objective_id: str) -> None: ) params = json.dumps({"Id": objective_id, "Index": position}) req = length_delimited(1, sila_string(params)) - await self._driver._call(_OBJ_SVC, "ChangeHardware", req, True) + await self.driver._call(_OBJ_SVC, "ChangeHardware", req, True) async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: available = await self.request_available_filter_cubes() @@ -591,18 +591,18 @@ async def change_filter_cube(self, position: int, filter_cube_id: str) -> None: ) params = json.dumps({"Id": filter_cube_id, "Index": position}) req = length_delimited(1, sila_string(params)) - await self._driver._call(_FC_SVC, "ChangeHardware", req, True) + await self.driver._call(_FC_SVC, "ChangeHardware", req, True) async def enter_objective_maintenance(self, position: int) -> None: - if self._driver.door_open: + if self.driver.door_open: raise RuntimeError("Cannot enter objective maintenance while the plate drawer is open.") params = json.dumps({"Index": position}) req = length_delimited(1, sila_string(params)) - await self._driver._initialize() - await self._driver._call(_OBJ_SVC, "EnterObjectiveMaintenance", req, True) + await self.driver._initialize() + await self.driver._call(_OBJ_SVC, "EnterObjectiveMaintenance", req, True) async def exit_objective_maintenance(self) -> None: - await self._driver._call(_OBJ_SVC, "ExitObjectiveMaintenance", b"", True) + await self.driver._call(_OBJ_SVC, "ExitObjectiveMaintenance", b"", True) # -- imaging -- @@ -610,10 +610,10 @@ async def _snap_images(self, labware_params: dict, snap_params: dict) -> List[di labware_json = json.dumps(labware_params) snap_json = json.dumps(snap_params) - await self._driver._initialize() + await self.driver._initialize() request = _snap_images_params(labware_json, snap_json) - confirmation_raw = await self._driver._call( + confirmation_raw = await self.driver._call( _SNAP_SVC, "SnapImages", request, with_lock=True, timeout=60.0 ) exec_uuid = decode_command_confirmation(confirmation_raw) @@ -623,7 +623,7 @@ async def _snap_images(self, labware_params: dict, snap_params: dict) -> List[di chunks: Dict[int, Dict[int, bytes]] = defaultdict(dict) checksums: Dict[int, int] = {} - for response_raw in await self._driver._stream( + for response_raw in await self.driver._stream( _SNAP_SVC, "SnapImages_Intermediate", uuid_request, @@ -634,7 +634,7 @@ async def _snap_images(self, labware_params: dict, snap_params: dict) -> List[di chunks[meta["blob_index"]][meta["packet_index"]] = chunk_data checksums[meta["blob_index"]] = meta["blob_checksum"] - await self._driver._call( + await self.driver._call( _SNAP_SVC, "SnapImages_Result", uuid_request, with_lock=True, timeout=60.0 ) diff --git a/pylabrobot/molecular_devices/imageXpress/pico/pico.py b/pylabrobot/molecular_devices/imageXpress/pico/pico.py index 8f5f0b00a2e..144a3576506 100644 --- a/pylabrobot/molecular_devices/imageXpress/pico/pico.py +++ b/pylabrobot/molecular_devices/imageXpress/pico/pico.py @@ -1,6 +1,6 @@ from typing import Dict, Optional -from pylabrobot.capabilities.microscopy import ImagingMode, MicroscopyCapability, Objective +from pylabrobot.capabilities.microscopy import ImagingMode, Microscopy, Objective from pylabrobot.device import Device from pylabrobot.resources import Resource, Rotation @@ -53,9 +53,9 @@ def __init__( model=model, ) Device.__init__(self, driver=driver) - self._driver: PicoDriver = driver + self.driver: PicoDriver = driver - self.microscopy = MicroscopyCapability( + self.microscopy = Microscopy( backend=PicoMicroscopyBackend( driver=driver, objectives=objectives, diff --git a/pylabrobot/molecular_devices/spectramax/backend.py b/pylabrobot/molecular_devices/spectramax/backend.py index 434d91f90a1..c73ad1c2958 100644 --- a/pylabrobot/molecular_devices/spectramax/backend.py +++ b/pylabrobot/molecular_devices/spectramax/backend.py @@ -410,13 +410,13 @@ async def wait_for_idle(self, timeout: int = 600): class _MolecularDevicesProtocol: """Mixin with shared _set_* command builders for Molecular Devices readers. - Subclasses must have ``self._driver: MolecularDevicesDriver``. + Subclasses must have ``self.driver: MolecularDevicesDriver``. """ - _driver: MolecularDevicesDriver + driver: MolecularDevicesDriver async def _read_now(self) -> None: - await self._driver.send_command("!READ") + await self.driver.send_command("!READ") async def _transfer_data(self, settings: MolecularDevicesSettings) -> List[Dict]: """Transfer data from the plate reader.""" @@ -433,13 +433,13 @@ async def _transfer_data(self, settings: MolecularDevicesSettings) -> List[Dict] all_reads = [] for _ in range(num_readings): - res = await self._driver.send_command("!TRANSFER") + res = await self.driver.send_command("!TRANSFER") data_str = res[1] read_data = self._parse_data(data_str, settings) all_reads.extend(read_data) return all_reads - res = await self._driver.send_command("!TRANSFER") + res = await self.driver.send_command("!TRANSFER") data_str = res[1] return self._parse_data(data_str, settings) @@ -518,7 +518,7 @@ def _parse_data(self, data_str: str, settings: MolecularDevicesSettings) -> List return measurements async def _set_clear(self) -> None: - await self._driver.send_command("!CLEAR DATA") + await self.driver.send_command("!CLEAR DATA") async def _set_mode(self, settings: MolecularDevicesSettings) -> None: cmd = f"!MODE {settings.read_type.value}" @@ -530,7 +530,7 @@ async def _set_mode(self, settings: MolecularDevicesSettings) -> None: cmd = "!MODE" scan_type = ss.excitation_emission_type or "SPECTRUM" cmd += f" {scan_type} {ss.start_wavelength} {ss.step} {ss.num_steps}" - await self._driver.send_command(cmd) + await self.driver.send_command(cmd) async def _set_wavelengths(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.ABS: @@ -540,15 +540,15 @@ async def _set_wavelengths(self, settings: MolecularDevicesSettings) -> None: wl_str = " ".join(wl_parts) if settings.path_check: wl_str += " 900 998" - await self._driver.send_command(f"!WAVELENGTH {wl_str}") + await self.driver.send_command(f"!WAVELENGTH {wl_str}") elif settings.read_mode in (ReadMode.FLU, ReadMode.POLAR, ReadMode.TIME): ex_wl_str = " ".join(map(str, settings.excitation_wavelengths)) em_wl_str = " ".join(map(str, settings.emission_wavelengths)) - await self._driver.send_command(f"!EXWAVELENGTH {ex_wl_str}") - await self._driver.send_command(f"!EMWAVELENGTH {em_wl_str}") + await self.driver.send_command(f"!EXWAVELENGTH {ex_wl_str}") + await self.driver.send_command(f"!EMWAVELENGTH {em_wl_str}") elif settings.read_mode == ReadMode.LUM: wl_str = " ".join(map(str, settings.emission_wavelengths)) - await self._driver.send_command(f"!EMWAVELENGTH {wl_str}") + await self.driver.send_command(f"!EMWAVELENGTH {wl_str}") else: raise NotImplementedError(f"{settings.read_mode} not supported") @@ -571,15 +571,15 @@ async def _set_plate_position(self, settings: MolecularDevicesSettings) -> None: x_pos_cmd = f"!XPOS {top_left_well_center.x:.3f} {dx:.3f} {num_cols}" y_pos_cmd = f"!YPOS {size_y - top_left_well_center.y:.3f} {dy:.3f} {num_rows}" - await self._driver.send_command(x_pos_cmd) - await self._driver.send_command(y_pos_cmd) + await self.driver.send_command(x_pos_cmd) + await self.driver.send_command(y_pos_cmd) async def _set_strip(self, settings: MolecularDevicesSettings) -> None: - await self._driver.send_command(f"!STRIP 1 {settings.plate.num_items_x}") + await self.driver.send_command(f"!STRIP 1 {settings.plate.num_items_x}") async def _set_shake(self, settings: MolecularDevicesSettings) -> None: if not settings.shake_settings: - await self._driver.send_command("!SHAKE OFF") + await self.driver.send_command("!SHAKE OFF") return ss = settings.shake_settings shake_mode = "ON" if ss.before_read or ss.between_reads else "OFF" @@ -591,33 +591,33 @@ async def _set_shake(self, settings: MolecularDevicesSettings) -> None: else: between_duration = 0 wait_duration = 0 - await self._driver.send_command(f"!SHAKE {shake_mode}") - await self._driver.send_command( + await self.driver.send_command(f"!SHAKE {shake_mode}") + await self.driver.send_command( f"!SHAKE {before_duration} {ki} {wait_duration} {between_duration} 0" ) async def _set_carriage_speed(self, settings: MolecularDevicesSettings) -> None: - await self._driver.send_command(f"!CSPEED {settings.carriage_speed.value}") + await self.driver.send_command(f"!CSPEED {settings.carriage_speed.value}") async def _set_read_stage(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): stage = "BOT" if settings.read_from_bottom else "TOP" - await self._driver.send_command(f"!READSTAGE {stage}") + await self.driver.send_command(f"!READSTAGE {stage}") async def _set_flashes_per_well(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): - await self._driver.send_command(f"!FPW {settings.flashes_per_well}") + await self.driver.send_command(f"!FPW {settings.flashes_per_well}") async def _set_pmt(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode not in (ReadMode.FLU, ReadMode.LUM, ReadMode.POLAR, ReadMode.TIME): return gain = settings.pmt_gain if gain == PmtGain.AUTO: - await self._driver.send_command("!AUTOPMT ON") + await self.driver.send_command("!AUTOPMT ON") else: gain_val = gain.value if isinstance(gain, PmtGain) else gain - await self._driver.send_command("!AUTOPMT OFF") - await self._driver.send_command(f"!PMT {gain_val}") + await self.driver.send_command("!AUTOPMT OFF") + await self.driver.send_command(f"!PMT {gain_val}") async def _set_filter(self, settings: MolecularDevicesSettings) -> None: if ( @@ -625,24 +625,24 @@ async def _set_filter(self, settings: MolecularDevicesSettings) -> None: and settings.cutoff_filters ): cf_str = " ".join(map(str, settings.cutoff_filters)) - await self._driver.send_command("!AUTOFILTER OFF") - await self._driver.send_command(f"!EMFILTER {cf_str}") + await self.driver.send_command("!AUTOFILTER OFF") + await self.driver.send_command(f"!EMFILTER {cf_str}") else: - await self._driver.send_command("!AUTOFILTER ON") + await self.driver.send_command("!AUTOFILTER ON") async def _set_calibrate(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.ABS: - await self._driver.send_command(f"!CALIBRATE {settings.calibrate.value}") + await self.driver.send_command(f"!CALIBRATE {settings.calibrate.value}") else: - await self._driver.send_command(f"!PMTCAL {settings.calibrate.value}") + await self.driver.send_command(f"!PMTCAL {settings.calibrate.value}") async def _set_order(self, settings: MolecularDevicesSettings) -> None: - await self._driver.send_command(f"!ORDER {settings.read_order.value}") + await self.driver.send_command(f"!ORDER {settings.read_order.value}") async def _set_speed(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.ABS: mode = "ON" if settings.speed_read else "OFF" - await self._driver.send_command(f"!SPEED {mode}") + await self.driver.send_command(f"!SPEED {mode}") async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.POLAR: @@ -651,13 +651,13 @@ async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: else: command = "CARCOL" value = settings.settling_time if settings.settling_time > 100 else 100 - await self._driver.send_command(f"!NVRAM {command} {value}") + await self.driver.send_command(f"!NVRAM {command} {value}") async def _set_tag(self, settings: MolecularDevicesSettings) -> None: if settings.read_mode == ReadMode.POLAR and settings.read_type == ReadType.KINETIC: - await self._driver.send_command("!TAG ON") + await self.driver.send_command("!TAG ON") else: - await self._driver.send_command("!TAG OFF") + await self.driver.send_command("!TAG OFF") async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: """Set the READTYPE command and the expected number of response fields.""" @@ -681,14 +681,14 @@ async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: else: raise ValueError(f"Unsupported read mode: {settings.read_mode}") - await self._driver.send_command(cmd, num_res_fields=num_res_fields) + await self.driver.send_command(cmd, num_res_fields=num_res_fields) async def _set_integration_time( self, settings: MolecularDevicesSettings, delay_time: int, integration_time: int ) -> None: if settings.read_mode == ReadMode.TIME: - await self._driver.send_command(f"!COUNTTIMEDELAY {delay_time}") - await self._driver.send_command(f"!COUNTTIME {integration_time * 0.001}") + await self.driver.send_command(f"!COUNTTIMEDELAY {delay_time}") + await self.driver.send_command(f"!COUNTTIME {integration_time * 0.001}") def _get_cutoff_filter_index_from_wavelength(self, wavelength: int) -> int: """Converts a wavelength to a cutoff filter index.""" @@ -725,7 +725,7 @@ class MolecularDevicesAbsorbanceBackend(_MolecularDevicesProtocol, AbsorbanceBac """Translates AbsorbanceBackend interface into Molecular Devices commands.""" def __init__(self, driver: MolecularDevicesDriver) -> None: - self._driver = driver + self.driver = driver @dataclass class AbsorbanceParams(BackendParams): @@ -789,7 +789,7 @@ async def read_absorbance( await self._set_readtype(settings) await self._read_now() - await self._driver.wait_for_idle(timeout=backend_params.timeout) + await self.driver.wait_for_idle(timeout=backend_params.timeout) dicts = await self._transfer_data(settings) return [ AbsorbanceResult( @@ -806,7 +806,7 @@ class MolecularDevicesTemperatureBackend(TemperatureControllerBackend): """Translates TemperatureControllerBackend interface into Molecular Devices commands.""" def __init__(self, driver: MolecularDevicesDriver) -> None: - self._driver = driver + self.driver = driver @property def supports_active_cooling(self) -> bool: @@ -814,7 +814,7 @@ def supports_active_cooling(self) -> bool: async def request_temperature(self) -> Tuple[float, float]: """Get (current_temp, set_point) from the device.""" - res = await self._driver.send_command("!TEMP") + res = await self.driver.send_command("!TEMP") if len(res) > 1: parts = res[1].split() else: @@ -831,7 +831,7 @@ async def request_current_temperature(self) -> float: async def set_temperature(self, temperature: float) -> None: if not (0 <= temperature <= 45): raise ValueError("Temperature must be between 0 and 45°C.") - await self._driver.send_command(f"!TEMP {temperature}") + await self.driver.send_command(f"!TEMP {temperature}") async def deactivate(self) -> None: - await self._driver.send_command("!TEMP 0") + await self.driver.send_command("!TEMP 0") diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py b/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py index cdaec275861..4407b0ae597 100644 --- a/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py +++ b/pylabrobot/molecular_devices/spectramax/spectramax_384_plus.py @@ -1,5 +1,5 @@ -from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability -from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.temperature_controlling import TemperatureController from pylabrobot.device import Device from pylabrobot.resources import Coordinate, PlateHolder, Resource @@ -20,7 +20,7 @@ class SpectraMax384PlusAbsorbanceBackend(MolecularDevicesAbsorbanceBackend): async def _set_readtype(self, settings: MolecularDevicesSettings) -> None: cmd = f"!READTYPE {'CUV' if settings.cuvette else 'PLA'}" - await self._driver.send_command(cmd, num_res_fields=1) + await self.driver.send_command(cmd, num_res_fields=1) async def _set_nvram(self, settings: MolecularDevicesSettings) -> None: pass @@ -57,9 +57,9 @@ def __init__( model="Molecular Devices SpectraMax 384 Plus", ) Device.__init__(self, driver=driver) - self._driver: MolecularDevicesDriver = driver - self.absorbance = AbsorbanceCapability(backend=SpectraMax384PlusAbsorbanceBackend(driver)) - self.tc = TemperatureControlCapability(backend=MolecularDevicesTemperatureBackend(driver)) + self.driver: MolecularDevicesDriver = driver + self.absorbance = Absorbance(backend=SpectraMax384PlusAbsorbanceBackend(driver)) + self.tc = TemperatureController(backend=MolecularDevicesTemperatureBackend(driver)) self._capabilities = [self.absorbance, self.tc] self.plate_holder = PlateHolder( diff --git a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py index bf591c0f996..84441169369 100644 --- a/pylabrobot/molecular_devices/spectramax/spectramax_m5.py +++ b/pylabrobot/molecular_devices/spectramax/spectramax_m5.py @@ -2,14 +2,14 @@ from typing import Dict, List, Optional, Union from pylabrobot.capabilities.capability import BackendParams -from pylabrobot.capabilities.plate_reading.absorbance import AbsorbanceCapability -from pylabrobot.capabilities.plate_reading.fluorescence import FluorescenceCapability +from pylabrobot.capabilities.plate_reading.absorbance import Absorbance +from pylabrobot.capabilities.plate_reading.fluorescence import Fluorescence from pylabrobot.capabilities.plate_reading.fluorescence.backend import FluorescenceBackend from pylabrobot.capabilities.plate_reading.fluorescence.standard import FluorescenceResult -from pylabrobot.capabilities.plate_reading.luminescence import LuminescenceCapability +from pylabrobot.capabilities.plate_reading.luminescence import Luminescence from pylabrobot.capabilities.plate_reading.luminescence.backend import LuminescenceBackend from pylabrobot.capabilities.plate_reading.luminescence.standard import LuminescenceResult -from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.capabilities.temperature_controlling import TemperatureController from pylabrobot.device import Device from pylabrobot.resources import Coordinate, PlateHolder, Resource from pylabrobot.resources.plate import Plate @@ -38,7 +38,7 @@ class SpectraMaxM5FluorescenceBackend(_MolecularDevicesProtocol, FluorescenceBac """Translates FluorescenceBackend interface into SpectraMax M5 commands.""" def __init__(self, driver: MolecularDevicesDriver) -> None: - self._driver = driver + self.driver = driver @dataclass class FluorescenceParams(BackendParams): @@ -117,7 +117,7 @@ async def read_fluorescence( await self._set_readtype(settings) await self._read_now() - await self._driver.wait_for_idle(timeout=backend_params.timeout) + await self.driver.wait_for_idle(timeout=backend_params.timeout) dicts = await self._transfer_data(settings) return [ FluorescenceResult( @@ -191,7 +191,7 @@ async def read_fluorescence_polarization( await self._set_readtype(settings) await self._read_now() - await self._driver.wait_for_idle(timeout=timeout) + await self.driver.wait_for_idle(timeout=timeout) return await self._transfer_data(settings) async def read_time_resolved_fluorescence( @@ -259,7 +259,7 @@ async def read_time_resolved_fluorescence( await self._set_nvram(settings) await self._read_now() - await self._driver.wait_for_idle(timeout=timeout) + await self.driver.wait_for_idle(timeout=timeout) return await self._transfer_data(settings) @@ -267,7 +267,7 @@ class SpectraMaxM5LuminescenceBackend(_MolecularDevicesProtocol, LuminescenceBac """Translates LuminescenceBackend interface into SpectraMax M5 commands.""" def __init__(self, driver: MolecularDevicesDriver) -> None: - self._driver = driver + self.driver = driver @dataclass class LuminescenceParams(BackendParams): @@ -337,7 +337,7 @@ async def read_luminescence( await self._set_readtype(settings) await self._read_now() - await self._driver.wait_for_idle(timeout=backend_params.timeout) + await self.driver.wait_for_idle(timeout=backend_params.timeout) dicts = await self._transfer_data(settings) return [ LuminescenceResult( @@ -380,11 +380,11 @@ def __init__( model="Molecular Devices SpectraMax M5", ) Device.__init__(self, driver=driver) - self._driver: MolecularDevicesDriver = driver - self.absorbance = AbsorbanceCapability(backend=MolecularDevicesAbsorbanceBackend(driver)) - self.luminescence = LuminescenceCapability(backend=SpectraMaxM5LuminescenceBackend(driver)) - self.fluorescence = FluorescenceCapability(backend=SpectraMaxM5FluorescenceBackend(driver)) - self.tc = TemperatureControlCapability(backend=MolecularDevicesTemperatureBackend(driver)) + self.driver: MolecularDevicesDriver = driver + self.absorbance = Absorbance(backend=MolecularDevicesAbsorbanceBackend(driver)) + self.luminescence = Luminescence(backend=SpectraMaxM5LuminescenceBackend(driver)) + self.fluorescence = Fluorescence(backend=SpectraMaxM5FluorescenceBackend(driver)) + self.tc = TemperatureController(backend=MolecularDevicesTemperatureBackend(driver)) self._capabilities = [self.absorbance, self.luminescence, self.fluorescence, self.tc] self.plate_holder = PlateHolder( diff --git a/pylabrobot/opentrons/temperature_module/http_driver.py b/pylabrobot/opentrons/temperature_module/http_driver.py index 860eaea432b..9fb421e2112 100644 --- a/pylabrobot/opentrons/temperature_module/http_driver.py +++ b/pylabrobot/opentrons/temperature_module/http_driver.py @@ -43,7 +43,7 @@ class OpentronsTemperatureModuleTemperatureBackend(TemperatureControllerBackend) """Translates ``TemperatureControllerBackend`` into Opentrons HTTP-API calls.""" def __init__(self, driver: OpentronsTemperatureModuleDriver): - self._driver = driver + self.driver = driver @property def supports_active_cooling(self) -> bool: @@ -51,15 +51,15 @@ def supports_active_cooling(self) -> bool: async def set_temperature(self, temperature: float): ot_api.modules.temperature_module_set_temperature( - celsius=temperature, module_id=self._driver.opentrons_id + celsius=temperature, module_id=self.driver.opentrons_id ) async def deactivate(self): - ot_api.modules.temperature_module_deactivate(module_id=self._driver.opentrons_id) + ot_api.modules.temperature_module_deactivate(module_id=self.driver.opentrons_id) async def request_current_temperature(self) -> float: modules = ot_api.modules.list_connected_modules() for module in modules: - if module["id"] == self._driver.opentrons_id: + if module["id"] == self.driver.opentrons_id: return cast(float, module["data"]["currentTemperature"]) - raise RuntimeError(f"Module with id '{self._driver.opentrons_id}' not found") + raise RuntimeError(f"Module with id '{self.driver.opentrons_id}' not found") diff --git a/pylabrobot/opentrons/temperature_module/temperature_module.py b/pylabrobot/opentrons/temperature_module/temperature_module.py index cfeefefd7e9..c5f26627bb0 100644 --- a/pylabrobot/opentrons/temperature_module/temperature_module.py +++ b/pylabrobot/opentrons/temperature_module/temperature_module.py @@ -1,7 +1,7 @@ from typing import Optional from pylabrobot.capabilities.temperature_controlling import ( - TemperatureControlCapability, + TemperatureController, TemperatureControllerBackend, ) from pylabrobot.device import Device, Driver @@ -78,8 +78,8 @@ def __init__( model="temperatureModuleV2", ) Device.__init__(self, driver=driver) - self._driver = driver - self.tc = TemperatureControlCapability(backend=tc_backend) + self.driver = driver + self.tc = TemperatureController(backend=tc_backend) self._capabilities = [self.tc] if child is not None: diff --git a/pylabrobot/opentrons/temperature_module/usb_driver.py b/pylabrobot/opentrons/temperature_module/usb_driver.py index caaedb1ddc4..3f31883517d 100644 --- a/pylabrobot/opentrons/temperature_module/usb_driver.py +++ b/pylabrobot/opentrons/temperature_module/usb_driver.py @@ -71,7 +71,7 @@ class OpentronsTemperatureModuleUSBTemperatureBackend(TemperatureControllerBacke """Translates ``TemperatureControllerBackend`` into USB serial driver commands.""" def __init__(self, driver: OpentronsTemperatureModuleUSBDriver): - self._driver = driver + self.driver = driver @property def supports_active_cooling(self) -> bool: @@ -79,10 +79,10 @@ def supports_active_cooling(self) -> bool: async def set_temperature(self, temperature: float): tmp_message = f"M104 S{temperature}\r\n" - await self._driver.send_and_check(tmp_message.encode("utf-8")) + await self.driver.send_and_check(tmp_message.encode("utf-8")) async def deactivate(self): - await self._driver.send_and_check(b"M18\r\n") + await self.driver.send_and_check(b"M18\r\n") async def request_current_temperature(self) -> float: - return await self._driver.query_temperature() + return await self.driver.query_temperature() diff --git a/pylabrobot/qinstruments/bioshake.py b/pylabrobot/qinstruments/bioshake.py index e687f658ffb..a5e1c7ec6ae 100644 --- a/pylabrobot/qinstruments/bioshake.py +++ b/pylabrobot/qinstruments/bioshake.py @@ -1,9 +1,9 @@ import asyncio from typing import Optional, Union -from pylabrobot.capabilities.shaking import ShakerBackend, ShakingCapability +from pylabrobot.capabilities.shaking import ShakerBackend, Shaker from pylabrobot.capabilities.temperature_controlling import ( - TemperatureControlCapability, + TemperatureController, TemperatureControllerBackend, ) from pylabrobot.device import Device, Driver @@ -119,7 +119,7 @@ class BioShakeShakerBackend(ShakerBackend): """Translates ShakerBackend calls into BioShake serial commands.""" def __init__(self, driver: BioShakeDriver): - self._driver = driver + self.driver = driver async def start_shaking(self, speed: float, acceleration: Union[int, float] = 0): if isinstance(speed, float): @@ -131,14 +131,14 @@ async def start_shaking(self, speed: float, acceleration: Union[int, float] = 0) f"Speed must be an integer or a whole number float, not {type(speed).__name__}" ) - min_speed = int(float(await self._driver.send_command(cmd="getShakeMinRpm", delay=0.2))) - max_speed = int(float(await self._driver.send_command(cmd="getShakeMaxRpm", delay=0.2))) + min_speed = int(float(await self.driver.send_command(cmd="getShakeMinRpm", delay=0.2))) + max_speed = int(float(await self.driver.send_command(cmd="getShakeMaxRpm", delay=0.2))) assert min_speed <= speed <= max_speed, ( f"Speed {speed} RPM is out of range. Allowed range is {min_speed}-{max_speed} RPM" ) - await self._driver.send_command(cmd=f"setShakeTargetSpeed{speed}") + await self.driver.send_command(cmd=f"setShakeTargetSpeed{speed}") if isinstance(acceleration, float): if not acceleration.is_integer(): @@ -150,10 +150,10 @@ async def start_shaking(self, speed: float, acceleration: Union[int, float] = 0) ) min_accel = int( - float(await self._driver.send_command(cmd="getShakeAccelerationMin", delay=0.2)) + float(await self.driver.send_command(cmd="getShakeAccelerationMin", delay=0.2)) ) max_accel = int( - float(await self._driver.send_command(cmd="getShakeAccelerationMax", delay=0.2)) + float(await self.driver.send_command(cmd="getShakeAccelerationMax", delay=0.2)) ) assert min_accel <= acceleration <= max_accel, ( @@ -161,8 +161,8 @@ async def start_shaking(self, speed: float, acceleration: Union[int, float] = 0) f"Allowed range is {min_accel}-{max_accel} seconds" ) - await self._driver.send_command(cmd=f"setShakeAcceleration{acceleration}", delay=0.2) - await self._driver.send_command(cmd="shakeOn", delay=0.2) + await self.driver.send_command(cmd=f"setShakeAcceleration{acceleration}", delay=0.2) + await self.driver.send_command(cmd="shakeOn", delay=0.2) async def stop_shaking(self, deceleration: Union[int, float] = 0): if isinstance(deceleration, float): @@ -176,10 +176,10 @@ async def stop_shaking(self, deceleration: Union[int, float] = 0): ) min_decel = int( - float(await self._driver.send_command(cmd="getShakeAccelerationMin", delay=0.2)) + float(await self.driver.send_command(cmd="getShakeAccelerationMin", delay=0.2)) ) max_decel = int( - float(await self._driver.send_command(cmd="getShakeAccelerationMax", delay=0.2)) + float(await self.driver.send_command(cmd="getShakeAccelerationMax", delay=0.2)) ) assert min_decel <= deceleration <= max_decel, ( @@ -187,8 +187,8 @@ async def stop_shaking(self, deceleration: Union[int, float] = 0): f"Allowed range is {min_decel}-{max_decel} seconds" ) - await self._driver.send_command(cmd=f"setShakeAcceleration{deceleration}", delay=0.2) - await self._driver.send_command(cmd="shakeOff", delay=0.2) + await self.driver.send_command(cmd=f"setShakeAcceleration{deceleration}", delay=0.2) + await self.driver.send_command(cmd="shakeOff", delay=0.2) # The firmware needs the motor to fully decelerate before ELM can operate. await asyncio.sleep(3) @@ -198,17 +198,17 @@ def supports_locking(self) -> bool: return True async def lock_plate(self): - await self._driver.send_command(cmd="setElmLockPos", delay=0.3) + await self.driver.send_command(cmd="setElmLockPos", delay=0.3) async def unlock_plate(self): - await self._driver.send_command(cmd="setElmUnlockPos", delay=0.3) + await self.driver.send_command(cmd="setElmUnlockPos", delay=0.3) class BioShakeTemperatureBackend(TemperatureControllerBackend): """Translates TemperatureControllerBackend calls into BioShake serial commands.""" def __init__(self, driver: BioShakeDriver, supports_active_cooling: bool = False): - self._driver = driver + self.driver = driver self._supports_active_cooling = supports_active_cooling @property @@ -216,8 +216,8 @@ def supports_active_cooling(self) -> bool: return self._supports_active_cooling async def set_temperature(self, temperature: float): - min_temp = int(float(await self._driver.send_command(cmd="getTempMin", delay=0.2))) - max_temp = int(float(await self._driver.send_command(cmd="getTempMax", delay=0.2))) + min_temp = int(float(await self.driver.send_command(cmd="getTempMin", delay=0.2))) + max_temp = int(float(await self.driver.send_command(cmd="getTempMax", delay=0.2))) assert min_temp <= temperature <= max_temp, ( f"Temperature {temperature} C is out of range. Allowed range is {min_temp}-{max_temp} C." @@ -230,15 +230,15 @@ async def set_temperature(self, temperature: float): raise ValueError(f"Temperature must be a whole number in 1/10 C, not {temperature_tenths}") temperature_tenths = int(temperature_tenths) - await self._driver.send_command(cmd=f"setTempTarget{temperature_tenths}", delay=0.2) - await self._driver.send_command(cmd="tempOn", delay=0.2) + await self.driver.send_command(cmd=f"setTempTarget{temperature_tenths}", delay=0.2) + await self.driver.send_command(cmd="tempOn", delay=0.2) async def request_current_temperature(self) -> float: - response = await self._driver.send_command(cmd="getTempActual", delay=0.2) + response = await self.driver.send_command(cmd="getTempActual", delay=0.2) return float(response) async def deactivate(self): - await self._driver.send_command(cmd="tempOff", delay=0.2) + await self.driver.send_command(cmd="tempOff", delay=0.2) class BioShake(PlateHolder, Device): @@ -276,17 +276,17 @@ def __init__( model=model, ) Device.__init__(self, driver=driver) - self._driver: BioShakeDriver = driver + self.driver: BioShakeDriver = driver - self.shaker: Optional[ShakingCapability] = None - self.tc: Optional[TemperatureControlCapability] = None + self.shaker: Optional[Shaker] = None + self.tc: Optional[TemperatureController] = None self._capabilities = [] if has_shaking: - self.shaker = ShakingCapability(backend=BioShakeShakerBackend(driver)) + self.shaker = Shaker(backend=BioShakeShakerBackend(driver)) self._capabilities.append(self.shaker) if has_temperature: - self.tc = TemperatureControlCapability( + self.tc = TemperatureController( backend=BioShakeTemperatureBackend(driver, supports_active_cooling=supports_active_cooling) ) self._capabilities.append(self.tc) diff --git a/pylabrobot/thermo_fisher/cytomat/cytomat.py b/pylabrobot/thermo_fisher/cytomat/cytomat.py index ed45c7799ea..2640ac038c0 100644 --- a/pylabrobot/thermo_fisher/cytomat/cytomat.py +++ b/pylabrobot/thermo_fisher/cytomat/cytomat.py @@ -1,10 +1,10 @@ import random from typing import List, Literal, Optional, Union, cast -from pylabrobot.capabilities.automated_retrieval import AutomatedRetrievalCapability -from pylabrobot.capabilities.humidity_controlling import HumidityControlCapability -from pylabrobot.capabilities.shaking import ShakingCapability -from pylabrobot.capabilities.temperature_controlling import TemperatureControlCapability +from pylabrobot.capabilities.automated_retrieval import AutomatedRetrieval +from pylabrobot.capabilities.humidity_controlling import HumidityController +from pylabrobot.capabilities.shaking import Shaker +from pylabrobot.capabilities.temperature_controlling import TemperatureController from pylabrobot.device import Device from pylabrobot.resources import ( Coordinate, @@ -26,12 +26,12 @@ class NoFreeSiteError(Exception): class Cytomat(Resource, Device): _racks: List[PlateCarrier] - _driver: CytomatBackend + driver: CytomatBackend loading_tray: PlateHolder - retrieval: AutomatedRetrievalCapability - tc: TemperatureControlCapability - humidity: HumidityControlCapability - shaker: ShakingCapability + retrieval: AutomatedRetrieval + tc: TemperatureController + humidity: HumidityController + shaker: Shaker def __init__( self, @@ -58,7 +58,7 @@ def __init__( model=model, ) Device.__init__(self, driver=driver) - self._driver: CytomatBackend = driver + self.driver: CytomatBackend = driver self.loading_tray = PlateHolder( name=f"{name}_tray", size_x=127.76, size_y=85.48, size_z=0, pedestal_size_z=0 @@ -69,14 +69,14 @@ def __init__( for rack in self._racks: self.assign_child_resource(rack, location=None) - self.retrieval = AutomatedRetrievalCapability(backend=driver) - self.tc = TemperatureControlCapability(backend=driver) - self.humidity = HumidityControlCapability(backend=driver) + self.retrieval = AutomatedRetrieval(backend=driver) + self.tc = TemperatureController(backend=driver) + self.humidity = HumidityController(backend=driver) caps = [self.tc, self.humidity, self.retrieval] if driver.model != CytomatType.C5C: - self.shaker = ShakingCapability(backend=driver) + self.shaker = Shaker(backend=driver) caps.append(self.shaker) self._capabilities = caps @@ -87,7 +87,7 @@ def racks(self) -> List[PlateCarrier]: async def setup(self, **backend_kwargs): await super().setup() - await self._driver.set_racks(self._racks) + await self.driver.set_racks(self._racks) def get_num_free_sites(self) -> int: return sum([len(rack.get_free_sites()) for rack in self._racks]) From 31933a68781f09c5e7a89319414ea774defd39e0 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 31 Mar 2026 21:18:33 -0700 Subject: [PATCH 26/69] Add SyringeDispensing and PeristalticDispensing capabilities Two new dispensing capabilities under bulk_dispensers/: - SyringeDispensing: dispense(plate, volumes={col: vol}), prime(plate, volume) - PeristalticDispensing: dispense(plate, volumes={col: vol}), prime(), purge() Both use BackendParams for device-specific parameters. Also adds BackendParams to PlateWashingCapability. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../capabilities/bulk_dispensers/__init__.py | 2 + .../bulk_dispensers/peristaltic/__init__.py | 2 + .../bulk_dispensers/peristaltic/backend.py | 58 +++++++++++++++++++ .../peristaltic/peristaltic.py | 56 ++++++++++++++++++ .../bulk_dispensers/syringe/__init__.py | 2 + .../bulk_dispensers/syringe/backend.py | 40 +++++++++++++ .../bulk_dispensers/syringe/syringe.py | 40 +++++++++++++ .../capabilities/plate_washing/backend.py | 36 ++++++++++-- .../plate_washing/plate_washing.py | 33 ++++++++--- 9 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 pylabrobot/capabilities/bulk_dispensers/__init__.py create mode 100644 pylabrobot/capabilities/bulk_dispensers/peristaltic/__init__.py create mode 100644 pylabrobot/capabilities/bulk_dispensers/peristaltic/backend.py create mode 100644 pylabrobot/capabilities/bulk_dispensers/peristaltic/peristaltic.py create mode 100644 pylabrobot/capabilities/bulk_dispensers/syringe/__init__.py create mode 100644 pylabrobot/capabilities/bulk_dispensers/syringe/backend.py create mode 100644 pylabrobot/capabilities/bulk_dispensers/syringe/syringe.py diff --git a/pylabrobot/capabilities/bulk_dispensers/__init__.py b/pylabrobot/capabilities/bulk_dispensers/__init__.py new file mode 100644 index 00000000000..c584ce59aca --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/__init__.py @@ -0,0 +1,2 @@ +from .peristaltic import PeristalticDispensingBackend, PeristalticDispensing +from .syringe import SyringeDispensingBackend, SyringeDispensing diff --git a/pylabrobot/capabilities/bulk_dispensers/peristaltic/__init__.py b/pylabrobot/capabilities/bulk_dispensers/peristaltic/__init__.py new file mode 100644 index 00000000000..bc4a60a4ea8 --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/peristaltic/__init__.py @@ -0,0 +1,2 @@ +from .backend import PeristalticDispensingBackend +from .peristaltic import PeristalticDispensing diff --git a/pylabrobot/capabilities/bulk_dispensers/peristaltic/backend.py b/pylabrobot/capabilities/bulk_dispensers/peristaltic/backend.py new file mode 100644 index 00000000000..0a4e836b8f7 --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/peristaltic/backend.py @@ -0,0 +1,58 @@ +from abc import ABCMeta, abstractmethod +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend +from pylabrobot.resources import Plate + + +class PeristalticDispensingBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for peristaltic pump dispensing devices.""" + + @abstractmethod + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + """Dispense liquid using the peristaltic pump. + + Args: + plate: Target plate. + volumes: Mapping of 1-indexed column number to volume in uL. + backend_params: Backend-specific parameters. + """ + + @abstractmethod + async def prime( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime peristaltic fluid lines. + + Args: + plate: Target plate. + volume: Prime volume in uL (mutually exclusive with duration). + duration: Prime duration in seconds (mutually exclusive with volume). + backend_params: Backend-specific parameters. + """ + + @abstractmethod + async def purge( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Purge peristaltic fluid lines. + + Args: + plate: Target plate. + volume: Purge volume in uL (mutually exclusive with duration). + duration: Purge duration in seconds (mutually exclusive with volume). + backend_params: Backend-specific parameters. + """ diff --git a/pylabrobot/capabilities/bulk_dispensers/peristaltic/peristaltic.py b/pylabrobot/capabilities/bulk_dispensers/peristaltic/peristaltic.py new file mode 100644 index 00000000000..7df0211acf0 --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/peristaltic/peristaltic.py @@ -0,0 +1,56 @@ +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready +from pylabrobot.resources import Plate + +from .backend import PeristalticDispensingBackend + + +class PeristalticDispensing(Capability): + """Peristaltic dispensing capability.""" + + def __init__(self, backend: PeristalticDispensingBackend): + super().__init__(backend=backend) + self.backend: PeristalticDispensingBackend = backend + + @need_capability_ready + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + """Dispense liquid using the peristaltic pump. + + Args: + plate: Target plate. + volumes: Mapping of 1-indexed column number to volume in uL. + backend_params: Backend-specific parameters. + """ + await self.backend.dispense(plate=plate, volumes=volumes, backend_params=backend_params) + + @need_capability_ready + async def prime( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime peristaltic fluid lines.""" + await self.backend.prime( + plate=plate, volume=volume, duration=duration, backend_params=backend_params + ) + + @need_capability_ready + async def purge( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Purge peristaltic fluid lines.""" + await self.backend.purge( + plate=plate, volume=volume, duration=duration, backend_params=backend_params + ) diff --git a/pylabrobot/capabilities/bulk_dispensers/syringe/__init__.py b/pylabrobot/capabilities/bulk_dispensers/syringe/__init__.py new file mode 100644 index 00000000000..d6d8305f50b --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/syringe/__init__.py @@ -0,0 +1,2 @@ +from .backend import SyringeDispensingBackend +from .syringe import SyringeDispensing diff --git a/pylabrobot/capabilities/bulk_dispensers/syringe/backend.py b/pylabrobot/capabilities/bulk_dispensers/syringe/backend.py new file mode 100644 index 00000000000..adaca7a3c6e --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/syringe/backend.py @@ -0,0 +1,40 @@ +from abc import ABCMeta, abstractmethod +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend +from pylabrobot.resources import Plate + + +class SyringeDispensingBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for syringe pump dispensing devices.""" + + @abstractmethod + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + """Dispense liquid using the syringe pump. + + Args: + plate: Target plate. + volumes: Mapping of 1-indexed column number to volume in uL. + Example: {1: 100, 2: 100, 3: 200, 7: 50} + backend_params: Backend-specific parameters. + """ + + @abstractmethod + async def prime( + self, + plate: Plate, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime the syringe pump system. + + Args: + plate: Target plate. + volume: Prime volume in uL. + backend_params: Backend-specific parameters. + """ diff --git a/pylabrobot/capabilities/bulk_dispensers/syringe/syringe.py b/pylabrobot/capabilities/bulk_dispensers/syringe/syringe.py new file mode 100644 index 00000000000..69fca39676b --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/syringe/syringe.py @@ -0,0 +1,40 @@ +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready +from pylabrobot.resources import Plate + +from .backend import SyringeDispensingBackend + + +class SyringeDispensing(Capability): + """Syringe dispensing capability.""" + + def __init__(self, backend: SyringeDispensingBackend): + super().__init__(backend=backend) + self.backend: SyringeDispensingBackend = backend + + @need_capability_ready + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + """Dispense liquid using the syringe pump. + + Args: + plate: Target plate. + volumes: Mapping of 1-indexed column number to volume in uL. + backend_params: Backend-specific parameters. + """ + await self.backend.dispense(plate=plate, volumes=volumes, backend_params=backend_params) + + @need_capability_ready + async def prime( + self, + plate: Plate, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime the syringe pump system.""" + await self.backend.prime(plate=plate, volume=volume, backend_params=backend_params) diff --git a/pylabrobot/capabilities/plate_washing/backend.py b/pylabrobot/capabilities/plate_washing/backend.py index 6518a4d2293..e3fab72542c 100644 --- a/pylabrobot/capabilities/plate_washing/backend.py +++ b/pylabrobot/capabilities/plate_washing/backend.py @@ -1,7 +1,7 @@ from abc import ABCMeta, abstractmethod from typing import Optional -from pylabrobot.capabilities.capability import CapabilityBackend +from pylabrobot.capabilities.capability import BackendParams, CapabilityBackend from pylabrobot.resources import Plate @@ -9,16 +9,31 @@ class PlateWashingBackend(CapabilityBackend, metaclass=ABCMeta): """Abstract backend for plate washing devices.""" @abstractmethod - async def aspirate(self, plate: Plate) -> None: - """Aspirate (remove) liquid from all wells.""" + async def aspirate( + self, + plate: Plate, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Aspirate (remove) liquid from all wells. + + Args: + plate: Target plate. + backend_params: Backend-specific parameters. + """ @abstractmethod - async def dispense(self, plate: Plate, volume: float) -> None: + async def dispense( + self, + plate: Plate, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> None: """Dispense liquid into all wells. Args: plate: Target plate. volume: Volume per well in uL. + backend_params: Backend-specific parameters. """ @abstractmethod @@ -27,6 +42,7 @@ async def wash( plate: Plate, cycles: int = 3, dispense_volume: Optional[float] = None, + backend_params: Optional[BackendParams] = None, ) -> None: """Perform wash cycles (repeated dispense + aspirate). @@ -34,8 +50,16 @@ async def wash( plate: Target plate. cycles: Number of wash cycles. dispense_volume: Volume per well per cycle in uL. If None, use device default. + backend_params: Backend-specific parameters. """ @abstractmethod - async def prime(self) -> None: - """Prime fluid lines.""" + async def prime( + self, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime fluid lines. + + Args: + backend_params: Backend-specific parameters. + """ diff --git a/pylabrobot/capabilities/plate_washing/plate_washing.py b/pylabrobot/capabilities/plate_washing/plate_washing.py index 7454f0cf1bc..1c44255d3aa 100644 --- a/pylabrobot/capabilities/plate_washing/plate_washing.py +++ b/pylabrobot/capabilities/plate_washing/plate_washing.py @@ -1,6 +1,6 @@ from typing import Optional -from pylabrobot.capabilities.capability import Capability, need_capability_ready +from pylabrobot.capabilities.capability import BackendParams, Capability, need_capability_ready from pylabrobot.resources import Plate from .backend import PlateWashingBackend @@ -14,19 +14,29 @@ def __init__(self, backend: PlateWashingBackend): self.backend: PlateWashingBackend = backend @need_capability_ready - async def aspirate(self, plate: Plate) -> None: + async def aspirate( + self, + plate: Plate, + backend_params: Optional[BackendParams] = None, + ) -> None: """Aspirate (remove) liquid from all wells.""" - await self.backend.aspirate(plate=plate) + await self.backend.aspirate(plate=plate, backend_params=backend_params) @need_capability_ready - async def dispense(self, plate: Plate, volume: float) -> None: + async def dispense( + self, + plate: Plate, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> None: """Dispense liquid into all wells. Args: plate: Target plate. volume: Volume per well in uL. + backend_params: Backend-specific parameters. """ - await self.backend.dispense(plate=plate, volume=volume) + await self.backend.dispense(plate=plate, volume=volume, backend_params=backend_params) @need_capability_ready async def wash( @@ -34,6 +44,7 @@ async def wash( plate: Plate, cycles: int = 3, dispense_volume: Optional[float] = None, + backend_params: Optional[BackendParams] = None, ) -> None: """Perform wash cycles (repeated dispense + aspirate). @@ -41,10 +52,16 @@ async def wash( plate: Target plate. cycles: Number of wash cycles. dispense_volume: Volume per well per cycle in uL. If None, use device default. + backend_params: Backend-specific parameters. """ - await self.backend.wash(plate=plate, cycles=cycles, dispense_volume=dispense_volume) + await self.backend.wash( + plate=plate, cycles=cycles, dispense_volume=dispense_volume, backend_params=backend_params, + ) @need_capability_ready - async def prime(self) -> None: + async def prime( + self, + backend_params: Optional[BackendParams] = None, + ) -> None: """Prime fluid lines.""" - await self.backend.prime() + await self.backend.prime(backend_params=backend_params) From 97dccdda7402c5dfe3a264f007cba528440f8731 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 31 Mar 2026 21:18:43 -0700 Subject: [PATCH 27/69] Migrate EL406 to Device/Driver/CapabilityBackend architecture - EL406Driver: FTDI I/O, batch management, device-level ops, queries - EL406PlateWashingBackend: manifold ops (wash, aspirate, dispense, prime) - EL406ShakingBackend: shake/soak - EL406SyringeDispensingBackend: syringe dispense/prime - EL406PeristalticDispensingBackend: peristaltic dispense/prime/purge Legacy code is thin wrappers delegating to new backends. All 385 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../agilent/biotek/el406/hello-world.ipynb | 216 +++ pylabrobot/agilent/__init__.py | 4 + pylabrobot/agilent/biotek/__init__.py | 1 + pylabrobot/agilent/biotek/el406/__init__.py | 6 + .../agilent/biotek/el406/architecture.md | 113 ++ pylabrobot/agilent/biotek/el406/driver.py | 954 ++++++++++ pylabrobot/agilent/biotek/el406/el406.py | 67 + pylabrobot/agilent/biotek/el406/enums.py | 91 + .../agilent/biotek/el406/error_codes.py | 247 +++ pylabrobot/agilent/biotek/el406/errors.py | 47 + pylabrobot/agilent/biotek/el406/helpers.py | 104 ++ .../el406/peristaltic_dispensing_backend.py | 552 ++++++ .../biotek/el406/plate_washing_backend.py | 1532 +++++++++++++++++ pylabrobot/agilent/biotek/el406/protocol.py | 107 ++ .../agilent/biotek/el406/shaking_backend.py | 184 ++ .../el406/syringe_dispensing_backend.py | 385 +++++ .../plate_washing/biotek/el406/actions.py | 162 +- .../plate_washing/biotek/el406/backend.py | 367 ++-- .../biotek/el406/communication.py | 562 +----- .../plate_washing/biotek/el406/enums.py | 98 +- .../plate_washing/biotek/el406/error_codes.py | 250 +-- .../plate_washing/biotek/el406/errors.py | 50 +- .../plate_washing/biotek/el406/helpers.py | 117 +- .../biotek/el406/helpers_tests.py | 6 +- .../plate_washing/biotek/el406/protocol.py | 111 +- .../plate_washing/biotek/el406/queries.py | 187 +- .../biotek/el406/steps/__init__.py | 34 +- .../plate_washing/biotek/el406/steps/_base.py | 32 - .../biotek/el406/steps/_manifold.py | 1414 +-------------- .../biotek/el406/steps/_peristaltic.py | 464 +---- .../biotek/el406/steps/_shake.py | 155 +- .../biotek/el406/steps/_syringe.py | 344 +--- .../biotek/el406/steps_aspirate_tests.py | 24 +- .../biotek/el406/steps_peristaltic_tests.py | 48 +- .../biotek/el406/steps_prime_tests.py | 74 +- .../biotek/el406/steps_shake_tests.py | 24 +- .../biotek/el406/steps_wash_tests.py | 114 +- 37 files changed, 5097 insertions(+), 4150 deletions(-) create mode 100644 docs/user_guide/agilent/biotek/el406/hello-world.ipynb create mode 100644 pylabrobot/agilent/biotek/el406/__init__.py create mode 100644 pylabrobot/agilent/biotek/el406/architecture.md create mode 100644 pylabrobot/agilent/biotek/el406/driver.py create mode 100644 pylabrobot/agilent/biotek/el406/el406.py create mode 100644 pylabrobot/agilent/biotek/el406/enums.py create mode 100644 pylabrobot/agilent/biotek/el406/error_codes.py create mode 100644 pylabrobot/agilent/biotek/el406/errors.py create mode 100644 pylabrobot/agilent/biotek/el406/helpers.py create mode 100644 pylabrobot/agilent/biotek/el406/peristaltic_dispensing_backend.py create mode 100644 pylabrobot/agilent/biotek/el406/plate_washing_backend.py create mode 100644 pylabrobot/agilent/biotek/el406/protocol.py create mode 100644 pylabrobot/agilent/biotek/el406/shaking_backend.py create mode 100644 pylabrobot/agilent/biotek/el406/syringe_dispensing_backend.py delete mode 100644 pylabrobot/legacy/plate_washing/biotek/el406/steps/_base.py diff --git a/docs/user_guide/agilent/biotek/el406/hello-world.ipynb b/docs/user_guide/agilent/biotek/el406/hello-world.ipynb new file mode 100644 index 00000000000..83d51a4e351 --- /dev/null +++ b/docs/user_guide/agilent/biotek/el406/hello-world.ipynb @@ -0,0 +1,216 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "879joj9zw2q", + "metadata": {}, + "source": [ + "# BioTek EL406\n", + "\n", + "The BioTek EL406 plate washer has four subsystems — manifold, syringe pump, peristaltic pump, and shaker — each exposed as a capability on the `EL406` device." + ] + }, + { + "cell_type": "markdown", + "id": "hqb175huvvn", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "mkf845m5cj", + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-03-31 17:04:26,351 - pylabrobot.agilent.biotek.el406.driver - INFO - EL406Driver setting up\n", + "2026-03-31 17:04:26,352 - pylabrobot.agilent.biotek.el406.driver - INFO - Timeout: 15.0 seconds\n", + "2026-03-31 17:04:26,435 - pylabrobot.io.ftdi - INFO - Successfully opened FTDI device: 1504309\n", + "2026-03-31 17:04:26,439 - pylabrobot.agilent.biotek.el406.driver - INFO - Serial: 38400 baud, 8N2\n", + "2026-03-31 17:04:26,441 - pylabrobot.agilent.biotek.el406.driver - INFO - Flow control: NONE\n", + "2026-03-31 17:04:26,459 - pylabrobot.agilent.biotek.el406.driver - INFO - Testing communication with device...\n", + "2026-03-31 17:04:26,489 - pylabrobot.agilent.biotek.el406.communication - INFO - EL406 communication test passed\n", + "2026-03-31 17:04:26,489 - pylabrobot.agilent.biotek.el406.communication - INFO - Sending INIT_STATE command (0xA0) to clear device state\n", + "2026-03-31 17:04:26,616 - pylabrobot.agilent.biotek.el406.driver - INFO - Communication test: PASSED\n", + "2026-03-31 17:04:26,616 - pylabrobot.agilent.biotek.el406.driver - INFO - Performing full instrument reset...\n", + "2026-03-31 17:04:26,616 - pylabrobot.agilent.biotek.el406.actions - INFO - Resetting instrument\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2026-03-31 17:04:41,188 - pylabrobot.agilent.biotek.el406.actions - INFO - Instrument reset complete\n", + "2026-03-31 17:04:41,189 - pylabrobot.agilent.biotek.el406.driver - INFO - Instrument reset: DONE\n", + "2026-03-31 17:04:41,190 - pylabrobot.agilent.biotek.el406.driver - INFO - EL406Driver setup complete\n" + ] + } + ], + "source": [ + "import os\n", + "\n", + "from pylabrobot.agilent.biotek.el406 import EL406\n", + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "os.environ[\"DYLD_LIBRARY_PATH\"] = \"/opt/homebrew/lib:\" + os.environ.get(\"DYLD_LIBRARY_PATH\", \"\")\n", + "el406 = EL406(name=\"el406\")\n", + "await el406.setup()\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" + ] + }, + { + "cell_type": "markdown", + "id": "sevahtcfwm", + "metadata": {}, + "source": "## Manifold (plate washing)\n\nThe wash manifold is the primary fluid system." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "rf7oo89jr7d", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.agilent.biotek.el406.plate_washing_backend import EL406PlateWashingBackend\n\n# Basic wash\nawait el406.washer.wash(plate, cycles=3, dispense_volume=300)\n\n# Wash with buffer and shake/soak\nawait el406.washer.wash(\n plate,\n cycles=3,\n dispense_volume=300,\n backend_params=EL406PlateWashingBackend.WashParams(\n buffer=\"A\",\n soak_duration=10,\n shake_duration=5,\n shake_intensity=\"Medium\",\n ),\n)\n\n# Aspirate and dispense\nawait el406.washer.aspirate(plate)\nawait el406.washer.dispense(plate, volume=200)\n\n# Prime\nawait el406.washer.prime(\n backend_params=EL406PlateWashingBackend.PrimeParams(\n plate=plate, volume=10000, buffer=\"A\",\n ),\n)" + }, + { + "cell_type": "markdown", + "id": "1y8kqv8xazr", + "metadata": {}, + "source": [ + "## Syringe pump\n", + "\n", + "Precise low-volume dispensing via dual syringe pumps (A/B)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "jd94kfuzdy", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.agilent.biotek.el406.syringe_dispensing_backend import EL406SyringeDispensingBackend\n\nawait el406.syringe.prime(plate, volume=5000, backend_params=EL406SyringeDispensingBackend.PrimeParams(syringe=\"A\"))\n\n# Same volume for all columns\nawait el406.syringe.dispense(plate, volumes={c: 50 for c in range(1, 13)})" + }, + { + "cell_type": "markdown", + "id": "wo6glnrodrb", + "metadata": {}, + "source": "Different volumes per column:" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ijfmjniqzzq", + "metadata": {}, + "outputs": [], + "source": "# Different volumes per column: columns 1-6 get 100 uL, columns 7-12 get 50 uL\nawait el406.syringe.dispense(plate, volumes={\n **{c: 100 for c in range(1, 7)},\n **{c: 50 for c in range(7, 13)},\n})" + }, + { + "cell_type": "markdown", + "id": "gky56c7k2h", + "metadata": {}, + "source": [ + "## Peristaltic pump\n", + "\n", + "Continuous-flow dispensing with cassette selection and row/column masking." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "drlarkoukr9", + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend import EL406PeristalticDispensingBackend\n\nawait el406.peristaltic.prime(plate, volume=300, backend_params=EL406PeristalticDispensingBackend.PrimeParams(flow_rate=\"High\"))\n\n# Same volume for all columns\nawait el406.peristaltic.dispense(plate, volumes={c: 100 for c in range(1, 13)})\n\n# Different volumes per column\nawait el406.peristaltic.dispense(plate, volumes={\n **{c: 200 for c in range(1, 7)},\n **{c: 100 for c in range(7, 13)},\n})\n\nawait el406.peristaltic.purge(plate, volume=300, backend_params=EL406PeristalticDispensingBackend.PrimeParams(flow_rate=\"High\"))" + }, + { + "cell_type": "markdown", + "id": "k1w9qy1vpti", + "metadata": {}, + "source": "## Shaking\n\nThe EL406 shake is a single fire-and-forget command with duration baked in, so it uses the backend API directly rather than the generic `Shaker.shake(speed, duration)` interface." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "nee1ab1lhc", + "metadata": {}, + "outputs": [], + "source": [ + "await el406.shaker.backend.shake(plate, duration=30, intensity=\"Medium\")" + ] + }, + { + "cell_type": "markdown", + "id": "q0uusttfg8", + "metadata": {}, + "source": [ + "## Device-level operations" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "p8372uxod3m", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent.biotek.el406.enums import EL406WasherManifold\n", + "\n", + "await el406._driver.set_washer_manifold(EL406WasherManifold.TUBE_96_DUAL)\n", + "await el406._driver.reset()" + ] + }, + { + "cell_type": "markdown", + "id": "v7l85tg1hc", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9i2934a6xgi", + "metadata": {}, + "outputs": [], + "source": [ + "await el406.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.8" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py index 3a401545a88..350045a8d87 100644 --- a/pylabrobot/agilent/__init__.py +++ b/pylabrobot/agilent/__init__.py @@ -4,6 +4,10 @@ Cytation5, CytationBackend, CytationImagingConfig, + EL406, + EL406Driver, + EL406PlateWashingBackend, + EL406ShakingBackend, SynergyH1, SynergyH1Backend, ) diff --git a/pylabrobot/agilent/biotek/__init__.py b/pylabrobot/agilent/biotek/__init__.py index e7f0066b59e..ea6fce622a4 100644 --- a/pylabrobot/agilent/biotek/__init__.py +++ b/pylabrobot/agilent/biotek/__init__.py @@ -5,4 +5,5 @@ CytationBackend, CytationImagingConfig, ) +from .el406 import EL406, EL406Driver, EL406PlateWashingBackend, EL406ShakingBackend from .synergy_h1 import SynergyH1, SynergyH1Backend diff --git a/pylabrobot/agilent/biotek/el406/__init__.py b/pylabrobot/agilent/biotek/el406/__init__.py new file mode 100644 index 00000000000..8514016278c --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/__init__.py @@ -0,0 +1,6 @@ +from .driver import EL406Driver +from .el406 import EL406 +from .peristaltic_dispensing_backend import EL406PeristalticDispensingBackend +from .plate_washing_backend import EL406PlateWashingBackend +from .shaking_backend import EL406ShakingBackend +from .syringe_dispensing_backend import EL406SyringeDispensingBackend diff --git a/pylabrobot/agilent/biotek/el406/architecture.md b/pylabrobot/agilent/biotek/el406/architecture.md new file mode 100644 index 00000000000..944c81131bb --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/architecture.md @@ -0,0 +1,113 @@ +# BioTek EL406 — Architecture + +## Overview + +The EL406 plate washer has four subsystems: + +1. **Manifold** — aspirate, dispense, wash cycles, prime, auto-clean (vacuum-based) +2. **Syringe pumps** — precise dispense/prime via dual syringes (A/B) +3. **Peristaltic pumps** — continuous-flow dispense/prime/purge via cassettes +4. **Shaker** — plate shaking with soak periods + +Each subsystem maps to a separate capability in the new architecture. + +## Current migration status + +| Subsystem | Capability | Backend | Status | +|-----------|-----------|---------|--------| +| Manifold | `PlateWashingCapability` | `EL406PlateWashingBackend` | Done | +| Syringe | TBD | TBD | Not started | +| Peristaltic | TBD (`PumpingCapability`?) | TBD | Not started | +| Shaker | `ShakingCapability` (existing) | TBD | Not started | + +## Class diagram + +``` +EL406 (Device, Resource) + ├── _driver: EL406Driver + │ ├── FTDI I/O (setup/stop, serial config) + │ ├── Command sending (send_framed_command, send_action_command, send_step_command) + │ ├── Polling (poll_device_state, wait_until_ready) + │ ├── Batch management (batch context manager, start_batch) + │ └── Device-level ops (reset, home_motors, pause, resume, abort, set_washer_manifold) + │ + ├── washer: PlateWashingCapability + │ └── backend: EL406PlateWashingBackend + │ ├── PlateWashingBackend interface: aspirate, dispense, wash, prime + │ ├── Full manifold API: manifold_aspirate, manifold_dispense, + │ │ manifold_wash, manifold_prime, manifold_auto_clean + │ └── Command builders (_build_aspirate_command, _build_wash_composite_command, etc.) + │ + └── plate_holder: PlateHolder +``` + +## File layout + +``` +pylabrobot/agilent/biotek/el406/ +├── __init__.py # Exports EL406, EL406Driver, EL406PlateWashingBackend +├── driver.py # EL406Driver — FTDI I/O, lifecycle, device-level ops +├── plate_washing_backend.py # EL406PlateWashingBackend — manifold protocol encoding +├── el406.py # EL406 Device — wires driver + capabilities +└── architecture.md # This file +``` + +## Shared modules (in legacy, to be moved later) + +The driver and backend import utility modules that still live under the legacy path: + +- `legacy/.../protocol.py` — `build_framed_message()` wire framing +- `legacy/.../helpers.py` — `plate_to_wire_byte()`, plate defaults +- `legacy/.../enums.py` — `EL406WasherManifold`, `EL406Motor`, etc. +- `legacy/.../errors.py` — `EL406CommunicationError`, `EL406DeviceError` +- `legacy/.../error_codes.py` — error code lookup table + +These are protocol/hardware constants, not legacy API. They should eventually move to +`pylabrobot/agilent/biotek/el406/` once all subsystems are migrated. + +## Wire protocol + +- **Transport**: FTDI USB, 38400 baud, 8N2, no flow control +- **Framing**: 11-byte header (start marker, version, command LE16, constant, reserved, data length LE16, checksum LE16) + data +- **Flow**: Command → ACK (0x06) → response header + data. Step commands require STATUS_POLL (0x92) polling for completion. + +### Manifold command codes + +| Command | Code | Payload size | +|---------|------|-------------| +| Aspirate | 0xA5 | 22 bytes | +| Dispense | 0xA6 | 20 bytes | +| Wash | 0xA4 | 102 bytes | +| Prime | 0xA7 | 13 bytes | +| Auto-clean | 0xA8 | 8 bytes | + +## Usage + +```python +from pylabrobot.agilent.biotek.el406 import EL406 +from pylabrobot.resources import Plate + +el406 = EL406(name="washer") +await el406.setup() + +plate = Plate(...) # your plate resource + +# Simple API (via PlateWashingCapability) +await el406.washer.wash(plate, cycles=3, dispense_volume=300) +await el406.washer.aspirate(plate) +await el406.washer.dispense(plate, volume=200) + +# Full EL406 manifold API (via backend) +await el406.washer.backend.manifold_wash( + plate, cycles=5, buffer="B", dispense_flow_rate=9, + shake_duration=30, shake_intensity="Medium", +) +await el406.washer.backend.manifold_prime(plate, volume=10000, buffer="A") +await el406.washer.backend.manifold_auto_clean(plate, buffer="A", duration=120) + +# Device-level ops (via driver) +await el406._driver.reset() +await el406._driver.set_washer_manifold(EL406WasherManifold.TUBE_96_DUAL) + +await el406.stop() +``` diff --git a/pylabrobot/agilent/biotek/el406/driver.py b/pylabrobot/agilent/biotek/el406/driver.py new file mode 100644 index 00000000000..76a51d5c2cd --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/driver.py @@ -0,0 +1,954 @@ +"""EL406 Driver — owns FTDI I/O, connection lifecycle, and device-level operations. + +Protocol: 38400 baud, 8N2, no flow control, binary LE framing. +""" + +from __future__ import annotations + +import asyncio +import enum +import logging +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import NamedTuple, TypedDict, TypeVar + +from pylabrobot.device import Driver +from pylabrobot.io.binary import Reader +from pylabrobot.io.ftdi import FTDI +from pylabrobot.resources import Plate + +from .enums import ( + EL406Motor, + EL406MotorHomeType, + EL406Sensor, + EL406StepType, + EL406SyringeManifold, + EL406WasherManifold, +) +from .error_codes import get_error_message +from .errors import EL406CommunicationError, EL406DeviceError +from .helpers import plate_to_wire_byte +from .protocol import build_framed_message + +logger = logging.getLogger(__name__) + +LONG_READ_TIMEOUT = 120.0 # seconds, for long operations (wash cycles can take >30s) + +STATE_INITIAL = 1 +STATE_RUNNING = 2 +STATE_PAUSED = 3 +STATE_STOPPED = 4 + + +class DevicePollResult(NamedTuple): + """Parsed result from a STATUS_POLL response.""" + + validity: int + state: int + status: int + raw_response: bytes + + +class EL406Driver(Driver): + """FTDI-based driver for the BioTek EL406 plate washer. + + Owns the USB connection, low-level protocol framing, command serialization, + batch management, and device-level operations (reset, home, pause, etc.). + """ + + def __init__( + self, + timeout: float = 15.0, + device_id: str | None = None, + ) -> None: + super().__init__() + self.timeout = timeout + self._device_id = device_id + self.io: FTDI | None = None + self._command_lock: asyncio.Lock | None = None + self._in_batch: bool = False + + async def setup(self, skip_reset: bool = False) -> None: + """Set up communication with the EL406. + + Configures the FTDI USB interface with the correct parameters: + - 38400 baud + - 8 data bits, 2 stop bits, no parity (8N2) + - No flow control (disabled) + + If ``self.io`` is already set (e.g. injected mock for testing), + it is used as-is and ``setup()`` is not called on it again. + + Args: + skip_reset: If True, skip the instrument reset step. + + Raises: + RuntimeError: If pylibftdi is not installed or communication fails. + """ + self._command_lock = asyncio.Lock() + + logger.info("EL406Driver setting up") + logger.info(" Timeout: %.1f seconds", self.timeout) + + if self.io is None: + self.io = FTDI(human_readable_device_name="BioTek EL406", device_id=self._device_id) + await self.io.setup() + + # Configure serial parameters + logger.debug("Configuring serial parameters...") + try: + await self.io.set_baudrate(38400) + await self.io.set_line_property(8, 2, 0) # 8 data bits, 2 stop bits, no parity + logger.info(" Serial: 38400 baud, 8N2") + + SIO_DISABLE_FLOW_CTRL = 0x0 + await self.io.set_flowctrl(SIO_DISABLE_FLOW_CTRL) + logger.info(" Flow control: NONE") + + await self.io.set_rts(True) + await self.io.set_dtr(True) + logger.debug(" RTS and DTR enabled") + except Exception as e: + await self.io.stop() + self.io = None + raise EL406CommunicationError( + f"Failed to configure FTDI device: {e}", + operation="configure", + original_error=e, + ) from e + + # Purge buffers + logger.debug("Purging TX/RX buffers...") + await self._purge_buffers() + + # Test communication + logger.info("Testing communication with device...") + try: + await self._test_communication() + logger.info(" Communication test: PASSED") + except Exception as e: + logger.error(" Communication test: FAILED - %s", e) + raise + + if not skip_reset: + logger.info("Performing full instrument reset...") + await self.reset() + logger.info(" Instrument reset: DONE") + + logger.info("EL406Driver setup complete") + + async def stop(self) -> None: + """Close the FTDI connection.""" + logger.info("EL406Driver stopping") + if self.io is not None: + await self.io.stop() + self.io = None + + def serialize(self) -> dict: + """Serialize driver configuration.""" + return { + **super().serialize(), + "timeout": self.timeout, + "device_id": self._device_id, + } + + # --------------------------------------------------------------------------- + # Low-level I/O + # --------------------------------------------------------------------------- + + async def _write_to_device(self, data: bytes) -> None: + """Write bytes to the FTDI device, wrapping errors. + + Raises: + EL406CommunicationError: If the write fails. + """ + assert self.io is not None + try: + await self.io.write(data) + except Exception as e: + raise EL406CommunicationError( + f"Failed to write to device: {e}. Device may have disconnected.", + operation="write", + original_error=e, + ) from e + + async def _wait_for_ack(self, timeout: float, t0: float) -> None: + """Poll device for ACK byte within the remaining timeout window. + + Args: + timeout: Total timeout budget in seconds. + t0: Start timestamp (from ``time.monotonic()``). + + Raises: + RuntimeError: If device sends NAK. + TimeoutError: If no ACK within timeout. + """ + assert self.io is not None + while time.monotonic() - t0 < timeout: + byte = await self.io.read(1) + if byte: + if byte[0] == 0x15: # NAK + raise RuntimeError( + f"Device rejected command (NAK). Response: {byte!r}. " + "This may indicate an invalid command, bad parameters, or device busy state." + ) + if byte[0] == 0x06: # ACK + return + await asyncio.sleep(0.01) + raise TimeoutError("Timeout waiting for ACK") + + async def _read_exact_bytes(self, count: int, timeout: float, t0: float) -> bytes: + """Read exactly *count* bytes from the device, polling until done or timeout. + + Args: + count: Number of bytes to read. + timeout: Total timeout budget in seconds. + t0: Start timestamp (from ``time.monotonic()``). + + Returns: + Bytes read (may be shorter than *count* if timeout is reached). + """ + assert self.io is not None + buf = b"" + while len(buf) < count and time.monotonic() - t0 < timeout: + chunk = await self.io.read(count - len(buf)) + if chunk: + buf += chunk + else: + await asyncio.sleep(0.01) + return buf + + async def _purge_buffers(self) -> None: + """Purge the RX and TX buffers.""" + if self.io is None: + return + + try: + for _ in range(6): + await self.io.usb_purge_rx_buffer() + await self.io.usb_purge_tx_buffer() + except Exception as e: + raise EL406CommunicationError( + f"Failed to purge FTDI buffers: {e}. Device may have disconnected.", + operation="purge", + original_error=e, + ) from e + + async def _test_communication(self) -> None: + """Test communication with the device. + + Sends framed command 0x73 (115) and expects ACK (0x06) response. + + Raises: + RuntimeError: If communication test fails. + """ + if self.io is None: + raise RuntimeError("EL406 communication test failed: device not open") + + try: + framed_command = build_framed_message(command=0x73) + response = await self._send_framed_command(framed_command, timeout=self.timeout) + if 0x06 not in response: + raise RuntimeError( + f"EL406 communication test failed: expected ACK (0x06), got {response!r}" + ) + except TimeoutError as e: + raise RuntimeError(f"EL406 communication test failed: timeout - {e}") from e + + logger.info("EL406 communication test passed") + + # Send INIT_STATE (0xA0) command to clear device state + logger.info("Sending INIT_STATE command (0xA0) to clear device state") + init_state_cmd = build_framed_message(command=0xA0) + init_response = await self._send_framed_command(init_state_cmd, timeout=self.timeout) + logger.debug("INIT_STATE sent, response: %s", init_response.hex()) + + # --------------------------------------------------------------------------- + # Command sending + # --------------------------------------------------------------------------- + + async def _send_framed_command( + self, + framed_message: bytes, + timeout: float | None = None, + ) -> bytes: + """Send a framed command and wait for full response. + + The device responds to framed commands with: + - ACK (0x06) + 11-byte header + N-byte data + + This method reads the complete response to avoid leaving data in the buffer. + For ACK-only commands (e.g. TEST_COMM, INIT_STATE), the header wait acts as + an implicit settling delay that the device needs before accepting further + commands. + + Args: + framed_message: Complete framed message (from build_framed_message). + timeout: Timeout in seconds. + + Returns: + Complete response bytes (ACK + header + data). + + Raises: + TimeoutError: If timeout waiting for response. + """ + if self.io is None or self._command_lock is None: + raise RuntimeError("Device not initialized") + + if timeout is None: + timeout = self.timeout + + async with self._command_lock: + await self._purge_buffers() + + # Send header and data separately + header = framed_message[:11] + data = framed_message[11:] if len(framed_message) > 11 else b"" + + await self._write_to_device(header) + logger.debug("Sent header: %s", header.hex()) + + if data: + await asyncio.sleep(0.001) # Small delay between header and data + await self._write_to_device(data) + logger.debug("Sent data: %s", data.hex()) + logger.debug("Sent framed: %s", framed_message.hex()) + + # Read full response: ACK + 11-byte header + variable data + await self._wait_for_ack(timeout, time.monotonic()) + result = bytes([0x06]) + + # Fresh timestamp after ACK — header + data share a single timeout budget. + t0 = time.monotonic() + resp_header = await self._read_exact_bytes(11, timeout, t0) + + if len(resp_header) == 11: + result += resp_header + # Parse data length from header bytes 7-8 (little-endian) + data_len = Reader(resp_header[7:]).u16() + response_data = await self._read_exact_bytes(data_len, timeout, t0) + result += response_data + logger.debug("Full response: %s (%d bytes)", result.hex(), len(result)) + else: + logger.debug("ACK-only response (no frame): %s", result.hex()) + + return result + + async def _send_action_command( + self, + framed_message: bytes, + timeout: float | None = None, + ) -> bytes: + """Send an action command and wait for completion frame. + + Action commands (like reset, home_motors) work differently from query commands: + 1. Send command + 2. Device sends ACK immediately (acknowledging receipt) + 3. Device performs the physical action (takes time) + 4. Device sends completion frame when done + + This method waits for both the ACK and the completion frame. + + Args: + framed_message: Complete framed message (from build_framed_message). + timeout: Timeout in seconds for the entire operation including action completion. + + Returns: + Completion frame bytes (header + data). + + Raises: + TimeoutError: If timeout waiting for ACK or completion. + RuntimeError: If device rejects command (NAK). + """ + if self.io is None or self._command_lock is None: + raise RuntimeError("Device not initialized") + + if timeout is None: + timeout = LONG_READ_TIMEOUT # Default to long timeout for actions + + async with self._command_lock: + await self._purge_buffers() + + # Send header and data separately (matches _send_framed_command protocol) + header = framed_message[:11] + data = framed_message[11:] if len(framed_message) > 11 else b"" + + await self._write_to_device(header) + if data: + await asyncio.sleep(0.001) + await self._write_to_device(data) + logger.debug("Sent action command: %s", framed_message.hex()) + + t0 = time.monotonic() + + # Step 1: Wait for ACK (short timeout) + await self._wait_for_ack(min(timeout, self.timeout), t0) + logger.debug("Got ACK, waiting for completion...") + + # Step 2: Wait for completion frame (11-byte header + data) + header = await self._read_exact_bytes(11, timeout, t0) + if len(header) < 11: + raise TimeoutError(f"Timeout waiting for completion header (got {len(header)} bytes)") + + # Parse data length and read remaining data + data_len = Reader(header[7:]).u16() + data = await self._read_exact_bytes(data_len, timeout, t0) + + result = header + data + + logger.debug("Completion frame: %s (%d bytes)", result.hex(), len(result)) + + # Parse and log result + cmd_echo = Reader(result[2:]).u16() + response_data = result[11 : 11 + data_len] if len(result) >= 11 + data_len else b"" + logger.debug(" Command echo: 0x%04X, data: %s", cmd_echo, response_data.hex()) + + return result + + async def _send_framed_query( + self, + command: int, + data: bytes = b"", + timeout: float | None = None, + ) -> bytes: + """Send a framed query command and read full response with header and data. + + Sends the 11-byte header and optional data payload as separate USB writes, + then reads the full response: ACK + 11-byte response header + data. + + Args: + command: 16-bit command code + data: Optional data bytes to send with command + timeout: Timeout in seconds + + Returns: + Data bytes from response (header stripped). + + Raises: + RuntimeError: If device not initialized or response invalid. + TimeoutError: If timeout waiting for response. + """ + if self.io is None or self._command_lock is None: + raise RuntimeError("Device not initialized") + + if timeout is None: + timeout = self.timeout + + framed_message = build_framed_message(command, data) + + async with self._command_lock: + await self._purge_buffers() + + # Split header and data + msg_header = framed_message[:11] + msg_data = framed_message[11:] if len(framed_message) > 11 else b"" + + await self._write_to_device(msg_header) + logger.debug("Sent query header 0x%04X: %s", command, msg_header.hex()) + + if msg_data: + await asyncio.sleep(0.001) + await self._write_to_device(msg_data) + logger.debug("Sent query data: %s", msg_data.hex()) + + # Wait for ACK + try: + await self._wait_for_ack(timeout, time.monotonic()) + except RuntimeError as e: + raise RuntimeError( + f"Device rejected command 0x{command:04X} (NAK). Check command code and parameters." + ) from e + except TimeoutError as e: + raise TimeoutError(f"Timeout waiting for ACK (command 0x{command:04X})") from e + + t0 = time.monotonic() + # Read 11-byte response header (shares timeout budget with data) + resp_header = await self._read_exact_bytes(11, timeout, t0) + if len(resp_header) < 11: + raise TimeoutError(f"Timeout reading response header (got {len(resp_header)}/11 bytes)") + + logger.debug("Response header: %s", resp_header.hex()) + + # Parse data length from header bytes 7-8 (little-endian) + data_len = Reader(resp_header[7:]).u16() + logger.debug("Response data length: %d", data_len) + + # Read data bytes + response_data = await self._read_exact_bytes(data_len, timeout, t0) + if len(response_data) < data_len: + raise TimeoutError( + f"Timeout reading response data (got {len(response_data)}/{data_len} bytes)" + ) + + logger.debug("Response data: %s", response_data.hex()) + return response_data + + # --------------------------------------------------------------------------- + # Polling + # --------------------------------------------------------------------------- + + async def _poll_device_state(self) -> DevicePollResult: + """Send one STATUS_POLL and return the parsed device state. + + Returns: + DevicePollResult with validity, state, status, and raw_response. + + Raises: + EL406CommunicationError: If poll response is too short to parse. + """ + poll_command = build_framed_message(command=0x92) + poll_response = await self._send_framed_command(poll_command, timeout=self.timeout) + logger.debug("Status poll response (%d bytes): %s", len(poll_response), poll_response.hex()) + + if len(poll_response) < 21: + # Short response — return zeroed fields so callers can handle it + return DevicePollResult(validity=0, state=0, status=0, raw_response=poll_response) + + # Data layout (after ACK+header at offset 12): + # bytes 12-13: validity (little-endian, must be 0) + # bytes 14-15: state (little-endian) + # bytes 16-19: timestamp/counter + # byte 20: status code + r = Reader(poll_response[12:]) + validity = r.u16() + state = r.u16() + r.raw_bytes(4) # skip timestamp/counter (bytes 16-19) + status = r.u8() + + if validity != 0: + error_msg = get_error_message(validity) + logger.warning("Status poll returned error 0x%04X (%d): %s", validity, validity, error_msg) + + logger.debug("Status poll: validity=%d, state=%d, status=%d", validity, state, status) + return DevicePollResult( + validity=validity, state=state, status=status, raw_response=poll_response + ) + + async def _wait_until_ready(self, timeout: float = 5.0, poll_interval: float = 0.1) -> None: + """Poll until the device is no longer in STATE_RUNNING. + + Args: + timeout: Maximum time to wait in seconds. + poll_interval: Time between polls in seconds. + + Raises: + TimeoutError: If the device stays busy beyond *timeout*. + """ + t0 = time.monotonic() + while time.monotonic() - t0 < timeout: + poll = await self._poll_device_state() + if poll.state != STATE_RUNNING: + return + await asyncio.sleep(poll_interval) + raise TimeoutError(f"Device still busy (STATE_RUNNING) after {timeout}s waiting for readiness") + + async def _send_step_command( + self, + framed_message: bytes, + timeout: float | None = None, + poll_interval: float = 0.1, + ) -> bytes: + """Send a step command and poll for completion. + + Step commands (prime, dispense, aspirate, shake, etc.) require polling + for completion using STATUS_POLL (0x92) until the operation completes. + + Protocol flow: + 1. Wait for device to be ready (not RUNNING) + 2. Send step command (e.g., SYRINGE_PRIME 0xA2) + 3. Device ACKs immediately + 4. Poll with STATUS_POLL (0x92) repeatedly + 5. Check state in response to determine completion + + Args: + framed_message: Complete framed message (from build_framed_message). + timeout: Timeout in seconds for the entire operation. + poll_interval: Time between status polls in seconds. + + Returns: + Final status response bytes. + + Raises: + TimeoutError: If timeout waiting for completion. + EL406DeviceError: If device reports an error during the step. + RuntimeError: If device rejects command (NAK). + """ + if self.io is None: + raise RuntimeError("Device not initialized") + + if timeout is None: + timeout = LONG_READ_TIMEOUT + + logger.debug("Starting step command with timeout=%ss", timeout) + + # 1. Wait for device to be ready (not RUNNING) + await self._wait_until_ready(timeout=min(timeout, self.timeout)) + + # 2. Send the step command + logger.debug("Sending step command: %s", framed_message.hex()) + response = await self._send_framed_command(framed_message, timeout=min(timeout, self.timeout)) + logger.debug("Step command sent, got initial response: %s", response.hex()) + + # 3. Initial delay before polling + await asyncio.sleep(0.5) + + # 4. Poll for completion + t0 = time.monotonic() + poll_count = 0 + + logger.debug("Starting polling loop...") + + while time.monotonic() - t0 < timeout: + await asyncio.sleep(poll_interval) + poll_count += 1 + + poll = await self._poll_device_state() + logger.debug("Poll #%d: %d bytes", poll_count, len(poll.raw_response)) + + if poll.state in (STATE_INITIAL, STATE_STOPPED): + logger.debug("Step completed (state=%d) after %d polls", poll.state, poll_count) + if poll.validity != 0: + raise EL406DeviceError(poll.validity, get_error_message(poll.validity)) + return poll.raw_response + + if poll.state == STATE_RUNNING: + logger.debug("Step in progress (state=Running), continuing poll...") + elif poll.state == STATE_PAUSED: + logger.warning("Step is paused (state=3)") + elif poll.status == 0: + # Unknown state with status=0 means done + logger.debug("Done (unknown state=%d, status=0)", poll.state) + return poll.raw_response + else: + logger.debug("Unknown state=%d, status=%d, continuing...", poll.state, poll.status) + + raise TimeoutError(f"Timeout waiting for step completion after {timeout}s") + + # --------------------------------------------------------------------------- + # Batch management + # --------------------------------------------------------------------------- + + @asynccontextmanager + async def batch(self, plate: Plate) -> AsyncIterator[None]: + """Context manager for batching step commands. + + Each step command (manifold_wash, syringe_prime, etc.) automatically wraps + its execution in a batch. Use this context manager to group multiple step + commands into a single batch, avoiding repeated start/cleanup cycles. + + If already inside a batch, this is a no-op passthrough. + + Args: + plate: PLR Plate to configure for this batch. + + Example: + >>> async with driver.batch(plate_96): + ... await driver._send_step_command(framed_cmd) + """ + if self._in_batch: + yield + return + + self._in_batch = True + try: + await self.start_batch(plate_to_wire_byte(plate)) + yield + finally: + try: + await self.cleanup_after_protocol() + finally: + self._in_batch = False + + async def start_batch(self, wire_byte: int) -> None: + """Send START_STEP command to begin a batch of step operations. + + Use this function at the beginning of a protocol, before executing any step + commands. This puts the device in "ready to execute steps" mode. Must be + called once before running step commands like prime, dispense, aspirate, + shake, etc. + + This should be called: + - After setup() completes + - Before running any step commands + - Only once per batch of operations (not before each individual step) + + Args: + wire_byte: EL406 plate-type byte for the wire protocol. + """ + if self.io is None: + raise RuntimeError("Device not initialized - call setup() first") + + logger.info("Sending START_STEP to begin batch operations") + + # Send initialization commands before START_STEP + pre_batch_commands = [0xBF, 0xC1, 0xF2, 0xF4, 0x0154, 0x0102, 0x010A] + for cmd in pre_batch_commands: + cmd_frame = build_framed_message(cmd) + try: + resp = await self._send_framed_command(cmd_frame, timeout=self.timeout) + logger.debug("Command 0x%04X response: %s", cmd, resp.hex()) + except Exception as e: + logger.warning("Pre-batch command 0x%04X failed: %s", cmd, e) + + # Data byte is the plate type value (e.g., 0x04 for 96-well, 0x01 for 384-well). + start_step_data = bytes([wire_byte]) + start_step_cmd = build_framed_message(command=0x8D, data=start_step_data) + response = await self._send_framed_command(start_step_cmd, timeout=self.timeout) + logger.debug("START_STEP sent, response: %s", response.hex()) + + # --------------------------------------------------------------------------- + # Device-level operations + # --------------------------------------------------------------------------- + + async def abort( + self, + step_type: EL406StepType | None = None, + ) -> None: + """Abort a running operation. + + Args: + step_type: Optional step type to abort. If None, aborts current operation. + + Raises: + RuntimeError: If device not initialized. + TimeoutError: If timeout waiting for ACK response. + """ + logger.info( + "Aborting %s", + f"step type {step_type.name}" if step_type is not None else "current operation", + ) + + step_type_value = step_type.value if step_type is not None else 0 + data = bytes([step_type_value]) + framed_command = build_framed_message(command=0x89, data=data) + await self._send_framed_command(framed_command) + + async def pause(self) -> None: + """Pause a running operation.""" + logger.info("Pausing operation") + framed_command = build_framed_message(command=0x8A) + await self._send_framed_command(framed_command) + + async def resume(self) -> None: + """Resume a paused operation.""" + logger.info("Resuming operation") + framed_command = build_framed_message(command=0x8B) + await self._send_framed_command(framed_command) + + async def reset(self) -> None: + """Reset the instrument to a known state.""" + logger.info("Resetting instrument") + framed_command = build_framed_message(command=0x70) + await self._send_action_command(framed_command, timeout=LONG_READ_TIMEOUT) + logger.info("Instrument reset complete") + + async def _perform_end_of_batch(self) -> None: + """Perform end-of-batch activities - sends completion marker. + + NOTE: This command (140) is just a completion marker and does NOT: + - Stop the pump + - Home the syringes + + For a complete cleanup after a protocol, use cleanup_after_protocol() instead. + """ + logger.info("Performing end-of-batch activities (completion marker)") + framed_command = build_framed_message(command=0x8C) + await self._send_action_command(framed_command, timeout=60.0) + logger.info("End-of-batch marker sent") + + async def cleanup_after_protocol(self) -> None: + """Complete cleanup after running a protocol. + + This method performs the full cleanup sequence that the original BioTek + software does after all protocol steps complete: + 1. Home the syringes (XYZ motors) + 2. Send end-of-batch completion marker + + This is the recommended way to end a protocol run. + + Example: + >>> # Run protocol steps + >>> await backend.syringe_prime("A", 1000, 5, 2) + >>> await backend.syringe_prime("B", 1000, 5, 2) + >>> # Then cleanup + >>> await backend.cleanup_after_protocol() + """ + logger.info("Starting post-protocol cleanup") + + # Step 1: Home syringes + logger.info(" Homing motors...") + await self.home_motors(EL406MotorHomeType.HOME_XYZ_MOTORS) + + # Step 2: Send end-of-batch marker + logger.info(" Sending end-of-batch marker...") + await self._perform_end_of_batch() + + logger.info("Post-protocol cleanup complete") + + async def home_motors( + self, + home_type: EL406MotorHomeType, + motor: EL406Motor | None = None, + ) -> None: + """Home or verify motor positions.""" + logger.info( + "Home/verify motors: type=%s, motor=%s", + home_type.name, + motor.name if motor is not None else "default(0)", + ) + + motor_num = motor.value if motor is not None else 0 + data = bytes([home_type.value, motor_num]) + framed_command = build_framed_message(command=0xC8, data=data) + await self._send_action_command(framed_command, timeout=120.0) + logger.info("Motors homed") + + async def set_washer_manifold(self, manifold: EL406WasherManifold) -> None: + """Set the washer manifold type.""" + logger.info("Setting washer manifold to: %s", manifold.name) + data = bytes([manifold.value]) + framed_command = build_framed_message(command=0xD9, data=data) + await self._send_framed_command(framed_command) + logger.info("Washer manifold set to: %s", manifold.name) + + # --------------------------------------------------------------------------- + # Queries + # --------------------------------------------------------------------------- + + @staticmethod + def _extract_payload_byte(response_data: bytes) -> int: + """Extract the first payload byte, handling optional 2-byte header prefix.""" + return response_data[2] if len(response_data) > 2 else response_data[0] + + _E = TypeVar("_E", bound=enum.Enum) + + async def _query_enum(self, command: int, enum_cls: type[_E], label: str) -> _E: + """Send a framed query and parse the response byte as an *enum_cls* member.""" + logger.info("Querying %s", label) + response_data = await self._send_framed_query(command) + logger.debug("%s response data: %s", label.capitalize(), response_data.hex()) + value_byte = self._extract_payload_byte(response_data) + + try: + result = enum_cls(value_byte) + except ValueError: + logger.warning("Unknown %s: %d (0x%02X)", label, value_byte, value_byte) + raise ValueError( + f"Unknown {label}: {value_byte} (0x{value_byte:02X}). " + f"Valid types: {[m.name for m in enum_cls]}" + ) from None + + logger.info("%s: %s (0x%02X)", label.capitalize(), result.name, result.value) + return result + + async def request_washer_manifold(self) -> EL406WasherManifold: + """Query the installed washer manifold type.""" + return await self._query_enum( + command=0xD8, enum_cls=EL406WasherManifold, label="washer manifold type" + ) + + async def request_syringe_manifold(self) -> EL406SyringeManifold: + """Query the installed syringe manifold type.""" + return await self._query_enum( + command=0xBB, enum_cls=EL406SyringeManifold, label="syringe manifold type" + ) + + async def request_serial_number(self) -> str: + """Query the product serial number.""" + logger.info("Querying product serial number") + response_data = await self._send_framed_query(command=0x0100) + serial_number = response_data[2:].decode("ascii", errors="ignore").strip().rstrip("\x00") + logger.info("Product serial number: %s", serial_number) + return serial_number + + async def request_sensor_enabled(self, sensor: EL406Sensor) -> bool: + """Query whether a specific sensor is enabled.""" + logger.info("Querying sensor enabled status: %s", sensor.name) + response_data = await self._send_framed_query(command=0xD2, data=bytes([sensor.value])) + logger.debug("Sensor enabled response data: %s", response_data.hex()) + enabled = bool(self._extract_payload_byte(response_data)) + logger.info("Sensor %s enabled: %s", sensor.name, enabled) + return enabled + + class SyringeBoxInfo(TypedDict): + box_type: int + box_size: int + installed: bool + + async def request_syringe_box_info(self) -> SyringeBoxInfo: + """Get syringe box information.""" + logger.info("Querying syringe box info") + response_data = await self._send_framed_query(command=0xF6) + logger.debug("Syringe box info response data: %s", response_data.hex()) + + box_type = self._extract_payload_byte(response_data) + box_size = ( + response_data[3] + if len(response_data) > 3 + else (response_data[1] if len(response_data) > 1 else 0) + ) + installed = box_type != 0 + + info = self.SyringeBoxInfo(box_type=box_type, box_size=box_size, installed=installed) + logger.info("Syringe box info: %s", info) + return info + + async def request_peristaltic_installed(self, selector: int) -> bool: + """Check if a peristaltic pump is installed.""" + if selector < 0 or selector > 1: + raise ValueError(f"Invalid selector {selector}. Must be 0 (primary) or 1 (secondary).") + + logger.info("Querying peristaltic pump installed: selector=%d", selector) + response_data = await self._send_framed_query(command=0x0104, data=bytes([selector])) + logger.debug("Peristaltic installed response data: %s", response_data.hex()) + + installed = bool(self._extract_payload_byte(response_data)) + + logger.info("Peristaltic pump %d installed: %s", selector, installed) + return installed + + class InstrumentSettings(TypedDict): + washer_manifold: EL406WasherManifold + syringe_manifold: EL406SyringeManifold + syringe_box: "EL406Driver.SyringeBoxInfo" + peristaltic_pump_1: bool + peristaltic_pump_2: bool + + async def request_instrument_settings(self) -> InstrumentSettings: + """Get current instrument hardware configuration.""" + logger.info("Querying instrument settings from hardware") + + washer_manifold = await self.request_washer_manifold() + syringe_manifold = await self.request_syringe_manifold() + syringe_box = await self.request_syringe_box_info() + peristaltic_1 = await self.request_peristaltic_installed(0) + peristaltic_2 = await self.request_peristaltic_installed(1) + + settings = self.InstrumentSettings( + washer_manifold=washer_manifold, + syringe_manifold=syringe_manifold, + syringe_box=syringe_box, + peristaltic_pump_1=peristaltic_1, + peristaltic_pump_2=peristaltic_2, + ) + logger.info("Instrument settings: %s", settings) + return settings + + class SelfCheckResult(TypedDict): + success: bool + error_code: int + message: str + + async def run_self_check(self) -> SelfCheckResult: + """Run instrument self-check diagnostics.""" + logger.info("Running instrument self-check") + response_data = await self._send_framed_query(command=0x95, timeout=LONG_READ_TIMEOUT) + logger.debug("Self-check response data: %s", response_data.hex()) + error_code = self._extract_payload_byte(response_data) + success = error_code == 0 + + message = "Self-check passed" if success else f"Self-check failed (error code: {error_code})" + result = self.SelfCheckResult(success=success, error_code=error_code, message=message) + logger.info("Self-check result: %s", result["message"]) + return result diff --git a/pylabrobot/agilent/biotek/el406/el406.py b/pylabrobot/agilent/biotek/el406/el406.py new file mode 100644 index 00000000000..c2a244b1f8d --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/el406.py @@ -0,0 +1,67 @@ +"""BioTek EL406 plate washer device.""" + +from typing import Optional + +from pylabrobot.capabilities.bulk_dispensers.peristaltic import PeristalticDispensing +from pylabrobot.capabilities.plate_washing import PlateWashingCapability +from pylabrobot.capabilities.shaking import Shaker +from pylabrobot.capabilities.bulk_dispensers.syringe import SyringeDispensing +from pylabrobot.device import Device +from pylabrobot.resources import Coordinate, PlateHolder, Resource + +from .driver import EL406Driver +from .peristaltic_dispensing_backend import EL406PeristalticDispensingBackend +from .plate_washing_backend import EL406PlateWashingBackend +from .shaking_backend import EL406ShakingBackend +from .syringe_dispensing_backend import EL406SyringeDispensingBackend + + +class EL406(Resource, Device): + """BioTek EL406 plate washer. + + Example: + >>> el406 = EL406(name="el406") + >>> await el406.setup() + >>> await el406.washer.wash(plate, cycles=3) + >>> await el406.stop() + """ + + def __init__( + self, + name: str, + device_id: Optional[str] = None, + timeout: float = 15.0, + size_x: float = 0.0, + size_y: float = 0.0, + size_z: float = 0.0, + ): + driver = EL406Driver(timeout=timeout, device_id=device_id) + Resource.__init__( + self, + name=name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model="BioTek EL406", + ) + Device.__init__(self, driver=driver) + self._driver: EL406Driver = driver + + self.washer = PlateWashingCapability(backend=EL406PlateWashingBackend(driver)) + self.shaker = Shaker(backend=EL406ShakingBackend(driver)) + self.syringe = SyringeDispensing(backend=EL406SyringeDispensingBackend(driver)) + self.peristaltic = PeristalticDispensing(backend=EL406PeristalticDispensingBackend(driver)) + self._capabilities = [self.washer, self.shaker, self.syringe, self.peristaltic] + + self.plate_holder = PlateHolder( + name=name + "_plate_holder", + size_x=127.76, + size_y=85.48, + size_z=0, + pedestal_size_z=0, + child_location=Coordinate.zero(), + ) + self.assign_child_resource(self.plate_holder, location=Coordinate.zero()) + + def serialize(self) -> dict: + return {**Resource.serialize(self), **Device.serialize(self)} diff --git a/pylabrobot/agilent/biotek/el406/enums.py b/pylabrobot/agilent/biotek/el406/enums.py new file mode 100644 index 00000000000..8456c4ee576 --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/enums.py @@ -0,0 +1,91 @@ +"""EL406 enumeration types. + +This module contains all enumeration types used by the BioTek EL406 +plate washer backend. +""" + +from __future__ import annotations + +import enum + + +class EL406WasherManifold(enum.IntEnum): + """Washer manifold types.""" + + TUBE_96_DUAL = 0 + TUBE_192 = 1 + TUBE_128 = 2 + TUBE_96_SINGLE = 3 + DEEP_PIN_96 = 4 + NOT_INSTALLED = 255 + + +class EL406SyringeManifold(enum.IntEnum): + """Syringe manifold types.""" + + NOT_INSTALLED = 0 + TUBE_16 = 1 + TUBE_32_LARGE_BORE = 2 + TUBE_32_SMALL_BORE = 3 + TUBE_16_7 = 4 + TUBE_8 = 5 + PLATE_6_WELL = 6 + PLATE_12_WELL = 7 + PLATE_24_WELL = 8 + PLATE_48_WELL = 9 + + +class EL406Sensor(enum.IntEnum): + """Sensor types for the EL406.""" + + VACUUM = 0 # Vacuum sensor + WASTE = 1 # Waste container sensor + FLUID = 2 # Fluid level sensor + FLOW = 3 # Flow sensor + FILTER_VAC = 4 # Filter vacuum sensor + PLATE = 5 # Plate presence sensor + + +class EL406StepType(enum.IntEnum): + """Step types for EL406 operations.""" + + UNDEFINED = 0 + P_DISPENSE = 1 # Peristaltic pump dispense + P_PRIME = 2 # Peristaltic pump prime + P_PURGE = 3 # Peristaltic pump purge + S_DISPENSE = 4 # Syringe dispense + S_PRIME = 5 # Syringe prime + M_WASH = 6 # Manifold wash + M_ASPIRATE = 7 # Manifold aspirate + M_DISPENSE = 8 # Manifold dispense + M_PRIME = 9 # Manifold prime + M_AUTO_CLEAN = 10 # Manifold auto-clean + SHAKE_SOAK = 11 # Shake/soak + + +class EL406Motor(enum.IntEnum): + """Motor types for the EL406.""" + + CARRIER_X = 0 # X-axis plate carrier motor + CARRIER_Y = 1 # Y-axis plate carrier motor + DISP_HEAD_Z = 2 # Dispense head Z-axis motor + WASH_HEAD_Z = 3 # Wash head Z-axis motor + SYRINGE_A = 4 # Syringe pump A motor + SYRINGE_B = 5 # Syringe pump B motor + PERI_PUMP_PRIMARY = 6 # Primary peristaltic pump motor + PERI_PUMP_SECONDARY = 7 # Secondary peristaltic pump motor + LEVEL_SENSE_Y = 8 # Level sense Y-axis motor + WASH_SYRINGE = 9 # Wash syringe motor + WASH_ASP_HEAD_Z = 10 # Wash aspirate head Z-axis motor + SINGLE_WELL_Y = 11 # Single well Y-axis motor + + +class EL406MotorHomeType(enum.IntEnum): + """Motor home types for the EL406.""" + + INIT_ALL_MOTORS = 1 # Initialize all motors + INIT_PERI_PUMP = 2 # Initialize peristaltic pump + HOME_MOTOR = 3 # Home a specific motor + HOME_XYZ_MOTORS = 4 # Home all XYZ motors + VERIFY_MOTOR = 5 # Verify a specific motor position + VERIFY_XYZ_MOTORS = 6 # Verify all XYZ motor positions diff --git a/pylabrobot/agilent/biotek/el406/error_codes.py b/pylabrobot/agilent/biotek/el406/error_codes.py new file mode 100644 index 00000000000..2cbbc39b504 --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/error_codes.py @@ -0,0 +1,247 @@ +""" +BioTek EL406 Error Codes + +This module contains error codes for the BioTek EL406 plate washer. + +The error codes provide human-readable descriptions for errors that may +occur during communication with the EL406 plate washer. +""" + +ERROR_CODES: dict[int, str] = { + 0x0175: "Error communicating with instrument software. didn't find find park opto sensor transition.", # 373 + 0x0C01: "Requested config/autocal data absent.", # 3073 + 0x0C02: "Calculated checksum didn't match checksum saved.", # 3074 + 0x0C03: "Config parameter out of range.", # 3075 + 0x1001: "Bootcode checksum error at powerup.", # 4097 + 0x1002: "Bootcode error unknown.", # 4098 + 0x1003: "Bootcode page program error.", # 4099 + 0x1004: "Bootcode block size error.", # 4100 + 0x1005: "Bootcode invalid processor signature.", # 4101 + 0x1006: "Bootcode memory exceeded.", # 4102 + 0x1007: "Bootcode invalid slave port.", # 4103 + 0x1008: "Bootcode invalid slave response.", # 4104 + 0x1009: "Bootcode invalid processor detected.", # 4105 + 0x100A: "Bootcode checksum error at powerup.", # 4106 + 0x100B: "Bootcode checksum error at powerup.", # 4107 + 0x100C: "Bootcode checksum error at powerup.", # 4108 + 0x100D: "Bootcode checksum error at powerup.", # 4109 + 0x100E: "Bootcode checksum error at powerup.", # 4110 + 0x100F: "Bootcode checksum error at powerup.", # 4111 + 0x1010: "Bootcode download checksum error.", # 4112 + 0x1250: "UI Processor internal RAM failure.", # 4688 + 0x1251: "MC Processor internal RAM failure.", # 4689 + 0x1300: "Invalid syringe", # 4864 + 0x1301: "Syringe is not connected", # 4865 + 0x1302: "Unable to initialize syringe", # 4866 + 0x1303: "Unable to initialize syringe sensor clear", # 4867 + 0x1304: "Syringe dispense volume out of calibration range", # 4868 + 0x1305: "Invalid syringe operation", # 4869 + 0x1306: "Syringe A FMEA check error", # 4870 + 0x1307: "Syringe B FMEA check error", # 4871 + 0x1355: "The Peri-pump module is not configured", # 4949 + 0x1356: "Invalid Peri-pump dispense position", # 4950 + 0x1357: "The second Peri-pump module is required", # 4951 + 0x1358: "This instrument does not support 0.5 uL Peri-pump dispense volume", # 4952 + 0x1400: "No vacuum pressure detected after turning on the vacuum pump", # 5120 + 0x1401: "The waste bottles must be emptied before continuing", # 5121 + 0x1402: "The valve to be cycle is invalid", # 5122 + 0x1403: "The magnet adapter height is out of range", # 5123 + 0x1404: "Use of the selected plate type is restricted", # 5124 + 0x1405: "Z Axis height error", # 5125 + 0x1406: "Invalid Plate type", # 5126 + 0x1407: "Invalid Step type", # 5127 + 0x1408: "Invalid plate geometry", # 5128 + 0x1409: "Invalid carrier type", # 5129 + 0x140A: "Invalid carrier specified", # 5130 + 0x140B: "Invalid carrier specified", # 5131 + 0x140C: "Invalid carrier specified", # 5132 + 0x140D: "Invalid carrier specified", # 5133 + 0x140E: "Invalid carrier specified", # 5134 + 0x140F: "Invalid carrier specified", # 5135 + 0x1410: "Incompatible hardware configuration", # 5136 + 0x1411: "Invalid carrier specified", # 5137 + 0x1412: "Plate clearance error", # 5138 + 0x1413: "AutoPrime in progress. Please wait until AutoPrime completes.", # 5139 + 0x1414: "AutoPrime is cleaning up. Please wait until AutoPrime cleanup completes.", # 5140 + 0x1415: "An AutoPrime value is out-of-range.", # 5141 + 0x1416: "Vacuum pressure incorrectly detected prior to starting the vacuum pump.", # 5142 + 0x1417: "The autocal sensor was not detected in the back of the instrument.", # 5143 + 0x1430: "Strip washer syringe FMEA check error.", # 5168 + 0x1431: "Strip washer aspirate head not installed.", # 5169 + 0x1432: "Strip washer syringe box not connected.", # 5170 + 0x1433: "Bad step type pointer passed in when finding plate heights.", # 5171 + 0x1500: "There was no buffer fluid present at the start of a manifold-based protocol or at the start of an individual step.", # 5376 + 0x1501: "There was no buffer fluid present immediately before the manifold dispense sequence.", # 5377 + 0x1502: "The buffer valve selection is invalid", # 5378 + 0x1503: "The requested volume to be dispensed through the manifold is smaller than the minimum volume that will be dispensed by the time the DC dispense pump turns on and the dispense valve is opened.", # 5379 + 0x1504: "There was no buffer fluid detected flowing through the manifold tubing during a manifold dispense/prime operation.", # 5380 + 0x1505: "There was no buffer fluid present at the end of a manifold-based protocol or at the end of an individual step.", # 5381 + 0x1506: "The requested carrier Y-axis position is out of range.", # 5382 + 0x1514: "The Ultrasonic Advantage hardware is not configured.", # 5396 + 0x1515: "The low-flow cell-wash hardware is not configured", # 5397 + 0x1516: "Vacuum pressure issue for vacuum filtration", # 5398 + 0x1517: "The software could not read the vacuum filter hardware consistently.", # 5399 + 0x1600: "Ran out of on-board storage space", # 5632 + 0x1601: "Ran out of on-board storage space for P-Dispense steps", # 5633 + 0x1602: "Ran out of on-board storage space for P-Prime steps", # 5634 + 0x1603: "Ran out of on-board storage space for P-Purge steps", # 5635 + 0x1604: "Ran out of on-board storage space for S-Dispense steps", # 5636 + 0x1605: "Ran out of on-board storage space for S-Prime steps", # 5637 + 0x1606: "Ran out of on-board storage space for W-Wash steps", # 5638 + 0x1607: "Ran out of on-board storage space for W-Aspirate steps", # 5639 + 0x1608: "Ran out of on-board storage space for W-Dispense steps", # 5640 + 0x1609: "Ran out of on-board storage space for W-Prime steps", # 5641 + 0x160A: "Ran out of on-board storage space for W-AutoClean steps", # 5642 + 0x160B: "Ran out of on-board storage space for Shake/Soak steps", # 5643 + 0x160C: "Ran out of on-board storage space for 1536 Wash steps", # 5644 + 0x160D: "Invalid Step Type encountered", # 5645 + 0x160E: "Ran out of on-board storage space for P-Purge steps", # 5646 + 0x160F: "Ran out of on-board storage space for P-Purge steps", # 5647 + 0x1610: "Protocol transfer failed.", # 5648 + 0x1700: "Level sensor not installed.", # 5888 + 0x1701: "Level sensor framing error.", # 5889 + 0x1702: "Level sensor timing error.", # 5890 + 0x1703: "Level sensor unknown command.", # 5891 + 0x1704: "Level sensor parameter error.", # 5892 + 0x1705: "Level sensor address error.", # 5893 + 0x1706: "Level sensor error detected but not classified.", # 5894 + 0x1707: "Level sensor response cmd char != request cmd char.", # 5895 + 0x1708: "Level sensor command response not long enough.", # 5896 + 0x1709: "Level sensor command response address not equal to '0'.", # 5897 + 0x170A: "Level sensor command response checksum error.", # 5898 + 0x170B: "Level sensor timeout while looking for SOF char.", # 5899 + 0x170C: "Level sensor RX error - framing error.", # 5900 + 0x170D: "Level sensor RX error in Mode parameter.", # 5901 + 0x170E: "Level sensor RX error in Format parameter.", # 5902 + 0x170F: "Level sensor RX error in Sensitivity parameter.", # 5903 + 0x1710: "Level sensor RX error in Average parameter.", # 5904 + 0x1711: "Level sensor RX error in Temp Comp parameter.", # 5905 + 0x1712: "Level sensor RX error in SDC parameter.", # 5906 + 0x1713: "Level sensor RX error in SDE parameter.", # 5907 + 0x1714: "Level sensor RX error in setting configuration.", # 5908 + 0x1715: "Level sensor error in converting a read to a level.", # 5909 + 0x1716: "7 reads did not come up with at least 3 good ones.", # 5910 + 0x1717: "Level sensor echo range error.", # 5911 + 0x1718: "Level sensor echo width error.", # 5912 + 0x1719: "7 reads did not come up with at least 3 good ones.", # 5913 + 0x171A: "Level sensor - motor axis incorrect in FindAxisCenter().", # 5914 + 0x171B: "7 reads did not come up with at least 3 good ones.", # 5915 + 0x171C: "In FindAxisCenter() initial read not > threshold.", # 5916 + 0x171D: "7 reads did not come up with at least 3 good ones.", # 5917 + 0x171E: "Level sensor - no well edge found - reached step limit.", # 5918 + 0x171F: "Level sensor - repeated FindAxisCenter() did not converge.", # 5919 + 0x1720: "Level sensor corner cal memory checksum error.", # 5920 + 0x1721: "Level sensor A1 cal memory checksum error.", # 5921 + 0x1722: "Level sensor - carrier height wrong - plate test > 30mm.", # 5922 + 0x1723: "A plate read was started but not finished successfully.", # 5923 + 0x1724: "7 reads did not come up with at least 3 good ones.", # 5924 + 0x1725: "The range of the smallest 3 reads (of 7) was > 0.5mm.", # 5925 + 0x1726: "Input to McReqLvlSnsZPosn() out of range.", # 5926 + 0x1727: "The correction factor is out of range.", # 5927 + 0x1728: "7 reads did not come up with at least 3 good ones.", # 5928 + 0x1729: "FindLsyParkPosn() could not find the park position.", # 5929 + 0x172A: "Read Plate or Read One command to MC - invalid Read Type.", # 5930 + 0x172B: "Row or column was 0 - must start at 1.", # 5931 + 0x172C: "Well test error - previous config not loaded.", # 5932 + 0x172D: "Well test error - wrong well.", # 5933 + 0x172E: "7 reads did not come up with at least 3 good ones.", # 5934 + 0x172F: "7 reads did not come up with at least 3 good ones.", # 5935 + 0x1730: "7 reads did not come up with at least 3 good ones.", # 5936 + 0x1731: "7 reads did not come up with at least 3 good ones.", # 5937 + 0x1732: "Level sensor - config memory checksum error.", # 5938 + 0x1733: "Well positions have not been calculated.", # 5939 + 0x1734: "Level sense correction factor not been calculated.", # 5940 + 0x1735: "Doing a Carrier Test - no previous Z-Axis cal data in EEPROM.", # 5941 + 0x1736: "Attempted a Z-axis wash head move with Sensor Y not at park posn.", # 5942 + 0x1737: "Plate test did not find a plate.", # 5943 + 0x1738: "Level sensor - config memory checksum error.", # 5944 + 0x1739: "MC Not all level sensor cal and config data has been loaded.", # 5945 + 0x173A: "Level sensor transmission buffer should be empty before sending a command.", # 5946 + 0x173B: "Level sensor - Z-Cal, Z=0, current to cal > +/-0.75mm.", # 5947 + 0x173C: "Level sensor - Z-Cal, Z=0, factory cal < 23mm or > 29mm.", # 5948 + 0x173D: "Level sensor - Z-Cal, Z=0, post to pre > +/-0.3mm.", # 5949 + 0x173E: "Level sensor - Z-Cal, Z=0, < 15.0mm.", # 5950 + 0x173F: "7 reads did not produce at least 6 good ones.", # 5951 + 0x6100: "The Mini-Tube plate must be used with the Mini-Tube Carrier.", # 24832 + 0x6101: "The 405 TS does not support downloading basecode from the LHC.", # 24833 + 0x6102: "The Mini-Tube plate must be used with the Mini-Tube Carrier.", # 24834 + 0x6110: "The Verify Manifold Test input parameters file was not found.", # 24848 + 0x6111: "The user data file for the Verify Manifold Test could not be read in.", # 24849 + 0x6112: "The Verify Manifold Test was stopped by user.", # 24850 + 0x6113: "The Verify Manifold Test is not supported.", # 24851 + 0x6114: "Invalid well specified.", # 24852 + 0x6115: "Invalid well specified.", # 24853 + 0x6116: "Invalid well specified.", # 24854 + 0x6117: "Invalid well specified.", # 24855 + 0x6118: "Invalid well specified.", # 24856 + 0x6119: "Invalid well specified.", # 24857 + 0x611A: "Invalid well specified.", # 24858 + 0x611B: "Invalid well specified.", # 24859 + 0x611C: "Invalid well specified.", # 24860 + 0x611D: "Invalid well specified.", # 24861 + 0x611E: "Invalid well specified.", # 24862 + 0x611F: "Invalid well specified.", # 24863 + 0x6120: "The carrier is not level.", # 24864 + 0x6121: "The test had an aspirate scan error.", # 24865 + 0x6122: "The test had an dispense scan error.", # 24866 + 0x6123: "Center of well not found where expected for Verify test plate.", # 24867 + 0x6124: "Incorrect plate installed for Verify test.", # 24868 + 0x6125: "The well volume following an aspirate indicates insufficient aspiration.", # 24869 + 0x6126: "The well volume following a dispense indicates insufficient dispense.", # 24870 + 0x6127: "Scan data could not be returned from the instrument.", # 24871 + 0x6128: "Invalid well specified.", # 24872 + 0x6129: "This Verify Manifold Test step was not performed.", # 24873 + 0x6150: "The mean Dispense Volume is out of range.", # 24912 + 0x6151: "The Dispense CV % exceeds the maximum threshold.", # 24913 + 0x6152: "The Aspirate Rate is below the minimum threshold.", # 24914 + 0x6160: "This step requires Washer components to be installed and connected.", # 24928 + 0x6161: "The Strip Washer Manifold and the Plate Type are incompatible.", # 24929 + 0x6162: "The Strip Washer does not support this Plate Type", # 24930 + 0x6165: "This Peri-pump does not support single well dispensing.", # 24933 + 0x6166: "The instrument does not support single well dispensing.", # 24934 + 0x6167: "The Syringe Manifold can only be used with 6-well plates", # 24935 + 0x6168: "The Syringe Manifold can only be used with 12-well plates", # 24936 + 0x6169: "The Syringe Manifold can only be used with 24-well plates", # 24937 + 0x6170: "The Syringe Manifold can only be used with 48-well plates", # 24944 + 0x6171: "The Cassette for single well dispensing does not support this plate type", # 24945 + 0x8100: "Error communicating with instrument software. Message not acknowledged (NAK).", # 33024 + 0x8101: "Error communicating with instrument software. Timeout while waiting for serial message data.", # 33025 + 0x8102: "Error communicating with instrument software. Instrument busy and unable to process message.", # 33026 + 0x8103: "Error communicating with instrument software. Receive buffer overflow error.", # 33027 + 0x8104: "Error communicating with instrument software. Communication checksum error.", # 33028 + 0x8105: "Error communicating with instrument software. Invalid structure type in byMsgStructure header field.", # 33029 + 0x8106: "Error communicating with instrument software. Invalid destination in byMsgDestination header field.", # 33030 + 0x8107: "Error communicating with instrument software. Message sent to instrument is not supported.", # 33031 + 0x8108: "Error communicating with instrument software. Message body size exceeds max limit.", # 33032 + 0x8109: "Error communicating with instrument software. Max number of requests currently running and cannot run the latest request.", # 33033 + 0x810A: "Error communicating with instrument software. No request running when response request issued.", # 33034 + 0x810B: "Error communicating with instrument software. Receive buffer overflow error.", # 33035 + 0x810C: "Error communicating with instrument software. Response for outstanding request not ready yet.", # 33036 + 0x810D: "Error communicating with instrument software. To communicate, the instrument must be at the Main Menu.", # 33037 + 0x810E: "Error communicating with instrument software. One or more request parameters are not valid.", # 33038 + 0x810F: "Error communicating with instrument software. Command not valid in current state.", # 33039 + 0xA100: " not available.", # 41216 + 0xA101: " not available.", # 41217 + 0xA102: " not available.", # 41218 + 0xA103: " not available.", # 41219 + 0xA104: " not available.", # 41220 + 0xA300: " power supply level error.", # 41728 + 0xA301: "+5v logic power supply level error.", # 41729 + 0xA302: "+24v system/motor power supply level error.", # 41730 + 0xA303: "Internal +42v PeriPump power supply level error.", # 41731 + 0xA304: "Internal reference voltage error.", # 41732 + 0xA305: "External +42v PeriPump power supply level error.", # 41733 +} + + +def get_error_message(code: int) -> str: + """ + Get the error message for a given error code. + + Args: + code: The error code to look up. + + Returns: + The error message, or a default message if not found. + """ + return ERROR_CODES.get(code, f"Unknown error code: 0x{code:04X} ({code})") diff --git a/pylabrobot/agilent/biotek/el406/errors.py b/pylabrobot/agilent/biotek/el406/errors.py new file mode 100644 index 00000000000..12bbcab09dc --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/errors.py @@ -0,0 +1,47 @@ +"""EL406 exception classes. + +This module contains exception classes used by the BioTek EL406 +plate washer backend. +""" + +from __future__ import annotations + + +class EL406CommunicationError(Exception): + """Exception raised for FTDI/USB communication errors with the EL406. + + This exception is raised when low-level communication fails, such as: + - USB device disconnected + - FTDI driver errors + - Write/read failures + + Attributes: + operation: The operation that failed (e.g., "write", "read", "open"). + original_error: The underlying exception that caused this error. + """ + + def __init__( + self, + message: str, + operation: str = "", + original_error: Exception | None = None, + ) -> None: + super().__init__(message) + self.operation = operation + self.original_error = original_error + + +class EL406DeviceError(Exception): + """Exception raised when the EL406 device reports an error via the validity field. + + The device returns a non-zero validity code in the status poll response + when a step command fails (e.g., no buffer fluid, invalid syringe, hardware fault). + + Attributes: + error_code: The raw error code from the device (e.g., 0x1500). + message: Human-readable error description. + """ + + def __init__(self, error_code: int, message: str) -> None: + self.error_code = error_code + super().__init__(f"EL406 error 0x{error_code:04X}: {message}") diff --git a/pylabrobot/agilent/biotek/el406/helpers.py b/pylabrobot/agilent/biotek/el406/helpers.py new file mode 100644 index 00000000000..cdac55fcf13 --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/helpers.py @@ -0,0 +1,104 @@ +"""EL406 plate type defaults and helper functions.""" + +from __future__ import annotations + +from pylabrobot.resources import Plate + +# Threshold for distinguishing standard-height vs low-profile plates (in mm). +# Standard microplates are ~14mm tall; PCR/flanged plates are typically <12mm. +_LOW_PROFILE_THRESHOLD_MM = 12.0 + +# Wire byte → physical defaults for each EL406 plate format. +# Keys are the raw byte values sent on the wire protocol. +_WIRE_BYTE_DEFAULTS: dict[int, dict[str, int]] = { + 0: { # 1536-well standard + "dispenser_height": 250, + "dispense_z": 94, + "aspirate_z": 42, + "rows": 32, + "cols": 48, + }, + 1: { # 384-well standard + "dispenser_height": 333, + "dispense_z": 120, + "aspirate_z": 22, + "rows": 16, + "cols": 24, + }, + 2: { # 384-well PCR (low profile) + "dispenser_height": 230, + "dispense_z": 83, + "aspirate_z": 2, + "rows": 16, + "cols": 24, + }, + 4: { # 96-well + "dispenser_height": 336, + "dispense_z": 121, + "aspirate_z": 29, + "rows": 8, + "cols": 12, + }, + 14: { # 1536-well flanged (low profile) + "dispenser_height": 196, + "dispense_z": 93, + "aspirate_z": 13, + "rows": 32, + "cols": 48, + }, +} + + +def plate_to_wire_byte(plate: Plate) -> int: + """Resolve a PLR Plate to the EL406 wire protocol byte. + + Determines the format from well count, and uses plate height (``size_z``) + to distinguish standard vs low-profile variants for 384 and 1536 plates. + + Args: + plate: A PyLabRobot Plate resource. + + Returns: + Integer byte value for the EL406 wire protocol. + + Raises: + ValueError: If the plate well count is not 96, 384, or 1536. + """ + wells = plate.num_items + if wells == 96: + return 4 + if wells == 384: + return 2 if plate.get_size_z() < _LOW_PROFILE_THRESHOLD_MM else 1 + if wells == 1536: + return 14 if plate.get_size_z() < _LOW_PROFILE_THRESHOLD_MM else 0 + raise ValueError(f"Unsupported plate well count: {wells}. EL406 supports 96, 384, or 1536.") + + +def plate_defaults(plate: Plate) -> dict[str, int]: + """Return the physical defaults dict for a plate.""" + return _WIRE_BYTE_DEFAULTS[plate_to_wire_byte(plate)] + + +def plate_max_columns(plate: Plate) -> int: + """Return the number of columns for a plate.""" + return plate.num_items_x + + +def plate_max_row_groups(plate: Plate) -> int: + """Return the number of row groups for a plate. + + 96-well: 1 row group (no row selection). + 384-well: 2 row groups. + 1536-well: 4 row groups. + """ + return {12: 1, 24: 2, 48: 4}[plate.num_items_x] + + +def plate_well_count(plate: Plate) -> int: + """Return the well count for a plate (96, 384, or 1536).""" + return plate.num_items + + +def plate_default_z(plate: Plate) -> int: + """Return the default dispenser Z height for a plate.""" + return plate_defaults(plate)["dispenser_height"] diff --git a/pylabrobot/agilent/biotek/el406/peristaltic_dispensing_backend.py b/pylabrobot/agilent/biotek/el406/peristaltic_dispensing_backend.py new file mode 100644 index 00000000000..8ad8e0bb0eb --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/peristaltic_dispensing_backend.py @@ -0,0 +1,552 @@ +"""EL406 peristaltic pump step methods. + +Provides peristaltic_prime, peristaltic_dispense, and peristaltic_purge operations +plus their corresponding command builders. +""" + +from __future__ import annotations + +import logging +from typing import Literal + +from pylabrobot.io.binary import Writer +from pylabrobot.resources import Plate + +from .helpers import ( + plate_default_z, + plate_max_columns, + plate_max_row_groups, + plate_to_wire_byte, + plate_well_count, +) +from .protocol import build_framed_message, columns_to_column_mask, encode_column_mask +from dataclasses import dataclass +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.bulk_dispensers.peristaltic.backend import PeristalticDispensingBackend +from .driver import EL406Driver + +logger = logging.getLogger(__name__) + +PeristalticFlowRate = Literal["Low", "Medium", "High"] +Cassette = Literal["Any", "1uL", "5uL", "10uL"] + +PERISTALTIC_FLOW_RATE_MAP: dict[str, int] = {"Low": 0, "Medium": 1, "High": 2} + + +def cassette_to_byte(cassette: Cassette) -> int: + mapping = {"ANY": 0, "1UL": 1, "5UL": 2, "10UL": 3} + key = cassette.upper() + if key not in mapping: + raise ValueError(f"Invalid cassette '{cassette}'. Must be one of: Any, 1uL, 5uL, 10uL") + return mapping[key] + + +def encode_quadrant_mask_inverted( + rows: list[int] | None, + num_row_groups: int = 4, +) -> int: + """Encode row/quadrant selection as inverted bitmask. + + The protocol uses INVERTED encoding for the quadrant/row mask byte: + 0 = selected, 1 = deselected. This is the opposite of the well mask. + + Args: + rows: List of row numbers (1 to num_row_groups) to select, or None for all. + If None, returns 0x00 (all selected in inverted encoding). + num_row_groups: Number of valid row groups for this plate type (1, 2, or 4). + + Returns: + Single byte with inverted bit encoding (only lower num_row_groups bits used). + + Raises: + ValueError: If any row number is out of range. + """ + if rows is None: + return 0x00 + + max_mask = (1 << num_row_groups) - 1 + mask = max_mask + for row in rows: + if row < 1 or row > num_row_groups: + raise ValueError(f"Row number {row} out of range. Must be 1-{num_row_groups}.") + mask &= ~(1 << (row - 1)) + + return mask & 0xFF + + +def validate_peristaltic_flow_rate(flow_rate: PeristalticFlowRate) -> None: + if flow_rate not in PERISTALTIC_FLOW_RATE_MAP: + raise ValueError( + f"flow_rate must be one of {sorted(PERISTALTIC_FLOW_RATE_MAP)}, got {flow_rate!r}" + ) + + +class EL406PeristalticDispensingBackend(PeristalticDispensingBackend): + """Peristaltic dispensing backend for the BioTek EL406.""" + + @dataclass + class DispenseParams(BackendParams): + """Parameters for peristaltic dispense. + + Attributes: + flow_rate: Flow rate ("Low", "Medium", or "High"). + offset_x: X offset in mm (-12.5 to 12.5). + offset_y: Y offset in mm (-4.0 to 4.0). + offset_z: Z offset in mm (0.1-150.0). Default depends on plate type: + 33.6 for 96/384-well, 25.4 for 1536-well. + pre_dispense_volume: Pre-dispense volume in uL (0 to disable). + num_pre_dispenses: Number of pre-dispenses (default 2). + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + columns: List of 1-indexed column numbers to dispense to, or None for all. + For 96-well: 1-12, for 384-well: 1-24, for 1536-well: 1-48. + rows: List of 1-indexed row group numbers, or None for all. + For 96-well: only 1 (no selection). For 384-well: 1-2. For 1536-well: 1-4. + """ + + flow_rate: PeristalticFlowRate = "High" + offset_x: float = 0.0 + offset_y: float = 0.0 + offset_z: Optional[float] = None + pre_dispense_volume: float = 10.0 + num_pre_dispenses: int = 2 + cassette: Cassette = "Any" + columns: list[int] | None = None + rows: list[int] | None = None + + def __init__(self, driver: EL406Driver) -> None: + self._driver = driver + + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, self.DispenseParams): + backend_params = self.DispenseParams() + + # Group consecutive columns with the same volume, in ascending order + groups: list[tuple[float, list[int]]] = [] + for col in sorted(volumes.keys()): + vol = volumes[col] + if groups and groups[-1][0] == vol: + groups[-1][1].append(col) + else: + groups.append((vol, [col])) + + for vol, cols in groups: + params = self.DispenseParams( + flow_rate=backend_params.flow_rate, + offset_x=backend_params.offset_x, + offset_y=backend_params.offset_y, + offset_z=backend_params.offset_z, + pre_dispense_volume=backend_params.pre_dispense_volume, + num_pre_dispenses=backend_params.num_pre_dispenses, + cassette=backend_params.cassette, + columns=cols, + rows=backend_params.rows, + ) + await self._peristaltic_dispense(plate, volume=vol, params=params) + + @dataclass + class PrimeParams(BackendParams): + """Parameters for peristaltic prime and purge. + + Attributes: + flow_rate: Flow rate ("Low", "Medium", or "High"). + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + """ + + flow_rate: PeristalticFlowRate = "High" + cassette: Cassette = "Any" + + async def prime( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, self.PrimeParams): + backend_params = self.PrimeParams() + await self._peristaltic_prime(plate, volume=volume, duration=duration, params=backend_params) + + async def purge( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, self.PrimeParams): + backend_params = self.PrimeParams() + await self._peristaltic_purge(plate, volume=volume, duration=duration, params=backend_params) + + def _validate_peristaltic_well_selection( + self, + plate: Plate, + columns: list[int] | None, + rows: list[int] | None, + ) -> list[int] | None: + """Validate column/row selection and return column mask.""" + max_cols = plate_max_columns(plate) + if columns is not None: + for col in columns: + if col < 1 or col > max_cols: + raise ValueError(f"Column {col} out of range for plate type (1-{max_cols}).") + + max_rows = plate_max_row_groups(plate) + if rows is not None: + for row in rows: + if row < 1 or row > max_rows: + raise ValueError(f"Row {row} out of range for plate type (1-{max_rows}).") + + return columns_to_column_mask(columns, plate_wells=plate_well_count(plate)) + + def _validate_peristaltic_dispense_params( + self, + plate: Plate, + volume: float, + flow_rate: PeristalticFlowRate, + offset_x: int, + offset_y: int, + offset_z: int | None, + pre_dispense_volume: float, + columns: list[int] | None, + rows: list[int] | None, + ) -> tuple[int, int, list[int] | None]: + """Validate peristaltic dispense parameters and resolve defaults. + + Returns: + (offset_z, flow_rate_enum, column_mask) + """ + if not 1 <= volume <= 3000: + raise ValueError(f"Peri-pump dispense volume must be 1-3000 uL, got {volume}") + validate_peristaltic_flow_rate(flow_rate) + if not -125 <= offset_x <= 125: + raise ValueError(f"Peri-pump dispense X-axis offset must be -125..125, got {offset_x}") + if not -40 <= offset_y <= 40: + raise ValueError(f"Peri-pump dispense Y-axis offset must be -40..40, got {offset_y}") + + if offset_z is None: + offset_z = plate_default_z(plate) + if not 1 <= offset_z <= 1500: + raise ValueError(f"Peri-pump dispense Z-axis offset must be 1..1500, got {offset_z}") + + if pre_dispense_volume < 0: + raise ValueError(f"pre_dispense_volume must be non-negative, got {pre_dispense_volume}") + + column_mask = self._validate_peristaltic_well_selection(plate, columns, rows) + + return (offset_z, PERISTALTIC_FLOW_RATE_MAP[flow_rate], column_mask) + + async def _peristaltic_prime( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + params: Optional[PrimeParams] = None, + ) -> None: + """Prime the peristaltic fluid lines. + + Specify either ``volume`` (uL/tube) or ``duration`` (seconds), not both. + If neither is given, defaults to volume mode with 1000 uL. + + Note: Peristaltic prime has no buffer selection. + Use ``manifold_prime()`` for buffer-specific priming. + + Args: + plate: PLR Plate resource. + volume: Volume to prime in microliters. + duration: Fixed duration in seconds (alternative to volume). + flow_rate: Flow rate ("Low", "Medium", or "High"). + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + + Raises: + ValueError: If both volume and duration are specified, or if parameters are invalid. + """ + if volume is not None and duration is not None: + raise ValueError("Specify either volume or duration, not both.") + + if duration is not None: + if not 1 <= duration <= 300: + raise ValueError("duration must be 1-300 seconds") + prime_volume = 0.0 + prime_duration = duration + else: + if volume is None: + volume = 1000.0 + if not 1 <= volume <= 3000: + raise ValueError("volume must be 1-3000 uL (GUI limit)") + prime_volume = volume + prime_duration = 0 + + if params is None: + params = self.PrimeParams() + + validate_peristaltic_flow_rate(params.flow_rate) + + logger.info( + "Peristaltic prime: %.1f uL, flow rate %s, cassette %s", + prime_volume, params.flow_rate, params.cassette, + ) + + data = self._build_peristaltic_prime_command( + plate=plate, + volume=prime_volume, + duration=prime_duration, + flow_rate=PERISTALTIC_FLOW_RATE_MAP[params.flow_rate], + reverse=True, + cassette=params.cassette, + pump=1, + ) + framed_command = build_framed_message(command=0x90, data=data) + # Timeout: duration (if specified) + buffer for volume-based priming + prime_timeout = self._driver.timeout + prime_duration + 30 + async with self._driver.batch(plate): + await self._driver._send_step_command(framed_command, timeout=prime_timeout) + + async def _peristaltic_dispense( + self, + plate: Plate, + volume: float, + params: Optional[DispenseParams] = None, + ) -> None: + """Dispense liquid using the peristaltic pump. + + Args: + plate: PLR Plate resource. + volume: Dispense volume in microliters (1-3000). + params: Dispense parameters (flow rate, offsets, cassette, column/row selection). + """ + if params is None: + params = self.DispenseParams() + + # Convert mm → 0.1mm steps for wire protocol + offset_x_steps = round(params.offset_x * 10) + offset_y_steps = round(params.offset_y * 10) + offset_z_steps = round(params.offset_z * 10) if params.offset_z is not None else None + + offset_z_steps, flow_rate_enum, column_mask = self._validate_peristaltic_dispense_params( + plate=plate, + volume=volume, + flow_rate=params.flow_rate, + offset_x=offset_x_steps, + offset_y=offset_y_steps, + offset_z=offset_z_steps, + pre_dispense_volume=params.pre_dispense_volume, + columns=params.columns, + rows=params.rows, + ) + + logger.info( + "Peristaltic dispense: %.1f uL, flow rate %s, cassette %s", + volume, + params.flow_rate, + params.cassette, + ) + + data = self._build_peristaltic_dispense_command( + plate=plate, + volume=volume, + flow_rate=flow_rate_enum, + cassette=params.cassette, + offset_x=offset_x_steps, + offset_y=offset_y_steps, + offset_z=offset_z_steps, + pre_dispense_volume=params.pre_dispense_volume, + num_pre_dispenses=params.num_pre_dispenses, + column_mask=column_mask, + rows=params.rows, + pump=1, + ) + framed_command = build_framed_message(command=0x8F, data=data) + async with self._driver.batch(plate): + await self._driver._send_step_command(framed_command) + + async def _peristaltic_purge( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + params: Optional[PrimeParams] = None, + ) -> None: + """Purge the fluid lines using the peristaltic pump. + + Specify either ``volume`` (uL/tube) or ``duration`` (seconds), not both. + + PERISTALTIC_PURGE uses the same data format as PERISTALTIC_PRIME + (both send identical data bytes). + + Args: + plate: PLR Plate resource. + volume: Purge volume in microliters. + duration: Fixed duration in seconds (alternative to volume). + params: Prime/purge parameters (flow rate, cassette). + """ + if params is None: + params = self.PrimeParams() + + if volume is not None and duration is not None: + raise ValueError("Specify either volume or duration, not both.") + if volume is None and duration is None: + raise ValueError("Either volume or duration must be specified.") + + if duration is not None: + if not 1 <= duration <= 300: + raise ValueError("duration must be 1-300 seconds") + purge_volume = 0.0 + purge_duration = duration + else: + assert volume is not None + if not 1 <= volume <= 3000: + raise ValueError("volume must be 1-3000 uL (GUI limit)") + purge_volume = volume + purge_duration = 0 + + validate_peristaltic_flow_rate(params.flow_rate) + + logger.info( + "Peristaltic purge: %.1f uL, flow rate %s, cassette %s", + purge_volume, + params.flow_rate, + params.cassette, + ) + + data = self._build_peristaltic_prime_command( + plate=plate, + volume=purge_volume, + duration=purge_duration, + flow_rate=PERISTALTIC_FLOW_RATE_MAP[params.flow_rate], + reverse=True, + cassette=params.cassette, + pump=1, + ) + framed_command = build_framed_message(command=0x91, data=data) + purge_timeout = self._driver.timeout + purge_duration + 30 + async with self._driver.batch(plate): + await self._driver._send_step_command(framed_command, timeout=purge_timeout) + + # ========================================================================= + # COMMAND BUILDERS + # ========================================================================= + + def _build_peristaltic_prime_command( + self, + plate: Plate, + volume: float, + duration: int = 0, + flow_rate: int = 2, + reverse: bool = True, + cassette: Cassette = "Any", + pump: int = 1, + ) -> bytes: + """Build peristaltic prime command bytes. + + Protocol format (11 bytes): + Example: 04 2c 01 00 00 02 01 00 01 00 00 + + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1-2] Volume (LE) — 0x0000 when using duration mode + [3-4] Duration in seconds (LE) — 0x0000 when using volume mode + [5] Flow rate enum (0=Low, 1=Medium, 2=High) + [6] Reverse/submerge (0 or 1) + [7] Cassette type (Any: 0, 1uL: 1, 5uL: 2, 10uL: 3) + [8] Pump (Primary: 1, Secondary: 2) + [9-10] Padding (0x0000) + + Args: + volume: Prime volume in microliters (0 when using duration mode). + duration: Fixed duration in seconds (0 when using volume mode). + flow_rate: Flow rate (0=Low, 1=Medium, 2=High). + reverse: Whether to reverse/submerge after prime. + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + pump: Pump (1=Primary, 2=Secondary). + + Returns: + Command bytes (11 bytes). + """ + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u16(int(volume)) # [1-2] Volume (LE) + .u16(duration) # [3-4] Duration (LE) + .u8(flow_rate) # [5] Flow rate + .u8(1 if reverse else 0) # [6] Reverse/submerge + .u8(cassette_to_byte(cassette)) # [7] Cassette type + .u8(pump & 0xFF) # [8] Pump + .raw_bytes(b'\x00' * 2) # [9-10] Padding + .finish() + ) # fmt: skip + + def _build_peristaltic_dispense_command( + self, + plate: Plate, + volume: float, + flow_rate: int, + cassette: Cassette = "Any", + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 336, + pre_dispense_volume: float = 0.0, + num_pre_dispenses: int = 2, + column_mask: list[int] | None = None, + rows: list[int] | None = None, + pump: int = 1, + ) -> bytes: + """Build peristaltic dispense command bytes. + + Protocol format (24 bytes): + Example: 04 0a 00 02 00 00 00 50 01 0a 00 02 ff ff ff ff ff ff 00 01 00 00 00 00 + + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1-2] Volume (LE) + [3] Flow rate (0=Low, 1=Med, 2=High) + [4] Cassette type (Any: 0, 1uL: 1, 5uL: 2, 10uL: 3) + [5] Offset X (signed byte) + [6] Offset Y (signed byte) + [7-8] Offset Z (LE) + [9-10] Pre-dispense volume (LE, 0 if disabled) + [11] Num pre-dispenses + [12-17] Column mask (48 bits packed, normal: 1=selected) + [18] Row mask (4 bits packed, INVERTED: 0=selected, 1=deselected) + [19] Pump (Primary: 1, Secondary: 2) + [20-23] Padding + + Args: + volume: Dispense volume in microliters. + flow_rate: Flow rate (0=Low, 1=Medium, 2=High). + cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). + offset_x: X offset (signed, 0.1mm units). + offset_y: Y offset (signed, 0.1mm units). + offset_z: Z offset (0.1mm units). + pre_dispense_volume: Pre-dispense volume in uL. + num_pre_dispenses: Number of pre-dispenses (default 2). + column_mask: List of column indices (0-47) or None for all columns. + rows: List of row numbers (1-4) or None for all rows. + pump: Pump (1=Primary, 2=Secondary). + + Returns: + Command bytes (24 bytes). + """ + num_row_groups = plate_max_row_groups(plate) + + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u16(int(volume)) # [1-2] Volume (LE) + .u8(flow_rate) # [3] Flow rate + .u8(cassette_to_byte(cassette)) # [4] Cassette type + .i8(offset_x) # [5] Offset X + .i8(offset_y) # [6] Offset Y + .u16(offset_z) # [7-8] Offset Z (LE) + .u16(int(pre_dispense_volume)) # [9-10] Pre-dispense vol + .u8(num_pre_dispenses) # [11] Num pre-dispenses + .raw_bytes(encode_column_mask(column_mask)) # [12-17] Column mask + .u8(encode_quadrant_mask_inverted(rows, num_row_groups=num_row_groups)) # [18] Row mask + .u8(pump & 0xFF) # [19] Pump + .raw_bytes(b'\x00' * 4) # [20-23] Padding + .finish() + ) # fmt: skip diff --git a/pylabrobot/agilent/biotek/el406/plate_washing_backend.py b/pylabrobot/agilent/biotek/el406/plate_washing_backend.py new file mode 100644 index 00000000000..1ebb8d4b304 --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/plate_washing_backend.py @@ -0,0 +1,1532 @@ +"""EL406 manifold step methods. + +Provides manifold_aspirate, manifold_dispense, manifold_wash, manifold_prime, +and manifold_auto_clean operations plus their corresponding command builders. +""" + +from __future__ import annotations + +import logging +from typing import Literal + +from pylabrobot.io.binary import Writer +from pylabrobot.resources import Plate + +from dataclasses import dataclass +from typing import Optional + +from pylabrobot.capabilities.capability import BackendParams + +from .helpers import plate_defaults, plate_to_wire_byte +from .protocol import build_framed_message +from pylabrobot.capabilities.plate_washing.backend import PlateWashingBackend +from .driver import EL406Driver +Intensity = Literal["Variable", "Slow", "Medium", "Fast"] + +INTENSITY_TO_BYTE: dict[str, int] = { + "Variable": 0x01, + "Slow": 0x02, + "Medium": 0x03, + "Fast": 0x04, +} + + +def validate_intensity(intensity: Intensity) -> None: + if intensity not in {"Slow", "Medium", "Fast", "Variable"}: + raise ValueError( + f"intensity must be one of {sorted({'Slow', 'Medium', 'Fast', 'Variable'})}, " + f"got {intensity!r}" + ) + +logger = logging.getLogger(__name__) + +Buffer = Literal["A", "B", "C", "D"] +TravelRate = Literal["1", "2", "3", "4", "5", "1 CW", "2 CW", "3 CW", "4 CW", "6 CW"] + +TRAVEL_RATE_TO_BYTE: dict[str, int] = { + "1": 1, + "2": 2, + "3": 3, + "4": 4, + "5": 5, + "1 CW": 7, + "2 CW": 8, + "3 CW": 9, + "4 CW": 10, + "6 CW": 6, +} + + +def travel_rate_to_byte(rate: TravelRate) -> int: + if rate not in TRAVEL_RATE_TO_BYTE: + valid = sorted(TRAVEL_RATE_TO_BYTE.keys()) + raise ValueError( + f"Invalid travel rate '{rate}'. Must be one of: {', '.join(repr(r) for r in valid)}" + ) + return TRAVEL_RATE_TO_BYTE[rate] + + +def get_plate_wash_defaults(plate: Plate) -> dict: + pt = plate_defaults(plate) + return { + "dispense_volume": 300.0 if pt["cols"] == 12 else 100.0, + "dispense_z": pt["dispense_z"], + "aspirate_z": pt["aspirate_z"], + } + + +def validate_buffer(buffer: Buffer) -> None: + if buffer.upper() not in {"A", "B", "C", "D"}: + raise ValueError(f"Invalid buffer '{buffer}'. Must be one of: A, B, C, D") + + +def validate_flow_rate(flow_rate: int) -> None: + if not 1 <= flow_rate <= 9: + raise ValueError(f"Invalid flow rate {flow_rate}. Must be between 1 and 9.") + + +def validate_cycles(cycles: int) -> None: + if not 1 <= cycles <= 250: + raise ValueError(f"cycles must be 1-250, got {cycles}") + + +def validate_delay_ms(delay_ms: int) -> None: + if not 0 <= delay_ms <= 65535: + raise ValueError(f"delay_ms must be 0-65535, got {delay_ms}") + + +def validate_travel_rate(rate: int) -> None: + if not 1 <= rate <= 9: + raise ValueError(f"travel_rate must be 1-9, got {rate}") + + +class EL406PlateWashingBackend(PlateWashingBackend): + """Manifold plate washing backend for the BioTek EL406. + + Implements the abstract PlateWashingBackend interface and also exposes the + full EL406-specific manifold API for users who need fine-grained control. + """ + + @dataclass + class WashParams(BackendParams): + """Parameters for manifold wash. + + Attributes: + buffer: Buffer valve selection (A, B, C, D). Default A. + dispense_flow_rate: Flow rate for dispensing (1-9). Default 7. + aspirate_travel_rate: Travel rate for aspiration (1-9). Default 3. + soak_duration: Soak duration in seconds (0 to disable, 0-3599). Default 0. + shake_duration: Shake duration in seconds (0 to disable, 0-3599). Default 0. + shake_intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). + Default "Medium". + """ + + buffer: Buffer = "A" + dispense_flow_rate: int = 7 + aspirate_travel_rate: int = 3 + soak_duration: int = 0 + shake_duration: int = 0 + shake_intensity: Intensity = "Medium" + + @dataclass + class PrimeParams(BackendParams): + """Parameters for manifold prime. + + Attributes: + plate: PLR Plate resource. + volume: Prime volume in uL. Range: 5000-999000 uL. + Wire resolution: 1000 uL (1 mL). + buffer: Buffer valve selection (A, B, C, D). + flow_rate: Flow rate (3-11, default 9). + """ + + plate: Optional[Plate] = None + volume: float = 10000.0 + buffer: Buffer = "A" + flow_rate: int = 9 + + def __init__(self, driver: EL406Driver) -> None: + self._driver = driver + + async def aspirate( + self, + plate: Plate, + backend_params: Optional[BackendParams] = None, + ) -> None: + await self.manifold_aspirate(plate) + + async def dispense( + self, + plate: Plate, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + await self.manifold_dispense(plate, volume=volume) + + async def wash( + self, + plate: Plate, + cycles: int = 3, + dispense_volume: Optional[float] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, self.WashParams): + backend_params = self.WashParams() + await self.manifold_wash( + plate, + cycles=cycles, + dispense_volume=dispense_volume, + buffer=backend_params.buffer, + dispense_flow_rate=backend_params.dispense_flow_rate, + aspirate_travel_rate=backend_params.aspirate_travel_rate, + soak_duration=backend_params.soak_duration, + shake_duration=backend_params.shake_duration, + shake_intensity=backend_params.shake_intensity, + ) + + async def prime( + self, + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, self.PrimeParams): + raise NotImplementedError( + "prime() requires PrimeParams with plate and volume. " + "Use manifold_prime(plate, volume) directly." + ) + await self.manifold_prime( + backend_params.plate, + volume=backend_params.volume, + buffer=backend_params.buffer, + flow_rate=backend_params.flow_rate, + ) + + @staticmethod + def _validate_manifold_xy(x: int, y: int, label: str) -> None: + """Validate manifold X/Y offsets (X: -60..60, Y: -40..40).""" + if not -60 <= x <= 60: + raise ValueError(f"{label} X offset must be -60..60, got {x}") + if not -40 <= y <= 40: + raise ValueError(f"{label} Y offset must be -40..40, got {y}") + + @staticmethod + def _validate_aspirate_mode_params( + vacuum_filtration: bool, + travel_rate: TravelRate, + delay_ms: int, + vacuum_time_sec: int, + ) -> tuple[int, int]: + """Validate aspirate mode-specific params and return (time_value, rate_byte).""" + if not vacuum_filtration: + if travel_rate not in TRAVEL_RATE_TO_BYTE: + raise ValueError( + f"Invalid travel rate '{travel_rate}'. Must be one of: " + f"{', '.join(repr(r) for r in sorted(TRAVEL_RATE_TO_BYTE))}" + ) + if not 0 <= delay_ms <= 5000: + raise ValueError(f"Aspirate delay must be 0-5000 ms, got {delay_ms}") + return (delay_ms, travel_rate_to_byte(travel_rate)) + + if not 5 <= vacuum_time_sec <= 999: + raise ValueError(f"Vacuum filtration time must be 5-999 seconds, got {vacuum_time_sec}") + return (vacuum_time_sec, travel_rate_to_byte("3")) + + @classmethod + def _validate_aspirate_offsets( + cls, + offset_x: int, + offset_y: int, + offset_z: int, + secondary_aspirate: bool, + secondary_x: int, + secondary_y: int, + secondary_z: int, + ) -> None: + """Validate aspirate XYZ offset ranges (primary and secondary).""" + cls._validate_manifold_xy(offset_x, offset_y, "Aspirate") + if not 1 <= offset_z <= 210: + raise ValueError(f"Aspirate Z offset must be 1-210, got {offset_z}") + if secondary_aspirate: + cls._validate_manifold_xy(secondary_x, secondary_y, "Secondary") + if not 1 <= secondary_z <= 210: + raise ValueError(f"Secondary Z offset must be 1-210, got {secondary_z}") + + def _validate_aspirate_params( + self, + plate: Plate, + vacuum_filtration: bool, + travel_rate: TravelRate, + delay_ms: int, + vacuum_time_sec: int, + offset_x: int, + offset_y: int, + offset_z: int | None, + secondary_aspirate: bool, + secondary_x: int, + secondary_y: int, + secondary_z: int | None, + ) -> tuple[int, int, int, int]: + """Validate aspirate parameters and resolve plate-type defaults. + + Returns: + (offset_z, secondary_z, time_value, rate_byte) + """ + pt_defaults = get_plate_wash_defaults(plate) + if offset_z is None: + offset_z = pt_defaults["aspirate_z"] + if secondary_z is None: + secondary_z = pt_defaults["aspirate_z"] + + time_value, rate_byte = self._validate_aspirate_mode_params( + vacuum_filtration, + travel_rate, + delay_ms, + vacuum_time_sec, + ) + self._validate_aspirate_offsets( + offset_x, + offset_y, + offset_z, + secondary_aspirate, + secondary_x, + secondary_y, + secondary_z, + ) + return (offset_z, secondary_z, time_value, rate_byte) + + @staticmethod + def _validate_dispense_extras( + pre_dispense_volume: float, + pre_dispense_flow_rate: int, + vacuum_delay_volume: float, + ) -> None: + """Validate pre-dispense and vacuum-delay parameters for manifold dispense.""" + if pre_dispense_volume != 0 and not 25 <= pre_dispense_volume <= 3000: + raise ValueError( + f"Manifold pre-dispense volume must be 0 (disabled) or 25-3000 uL, " + f"got {pre_dispense_volume}" + ) + if not 3 <= pre_dispense_flow_rate <= 11: + raise ValueError( + f"Manifold pre-dispense flow rate must be 3-11, got {pre_dispense_flow_rate}" + ) + if not 0 <= vacuum_delay_volume <= 3000: + raise ValueError(f"Manifold vacuum delay volume must be 0-3000 uL, got {vacuum_delay_volume}") + + def _validate_dispense_params( + self, + plate: Plate, + volume: float, + buffer: Buffer, + flow_rate: int, + offset_x: int, + offset_y: int, + offset_z: int | None, + pre_dispense_volume: float, + pre_dispense_flow_rate: int, + vacuum_delay_volume: float, + ) -> int: + """Validate dispense parameters and resolve plate-type defaults. + + Returns: + Resolved offset_z. + """ + if offset_z is None: + pt_defaults = get_plate_wash_defaults(plate) + offset_z = pt_defaults["dispense_z"] + + if not 25 <= volume <= 3000: + raise ValueError(f"Manifold dispense volume must be 25-3000 uL, got {volume}") + validate_buffer(buffer) + if not 1 <= flow_rate <= 11: + raise ValueError(f"Manifold dispense flow rate must be 1-11, got {flow_rate}") + if flow_rate <= 2 and vacuum_delay_volume <= 0: + raise ValueError( + f"Flow rates 1-2 (cell wash) require vacuum_delay_volume > 0, " + f"got flow_rate={flow_rate} with vacuum_delay_volume={vacuum_delay_volume}" + ) + self._validate_manifold_xy(offset_x, offset_y, "Manifold dispense") + if not 1 <= offset_z <= 210: + raise ValueError(f"Manifold dispense Z offset must be 1-210, got {offset_z}") + self._validate_dispense_extras(pre_dispense_volume, pre_dispense_flow_rate, vacuum_delay_volume) + + return offset_z + + def _resolve_wash_defaults( + self, + plate: Plate, + dispense_volume: float | None, + dispense_z: int | None, + aspirate_z: int | None, + secondary_z: int | None, + final_secondary_z: int | None, + ) -> tuple[float, int, int, int, int]: + """Resolve plate-type-aware defaults for wash parameters.""" + pt_defaults = get_plate_wash_defaults(plate) + if dispense_volume is None: + dispense_volume = pt_defaults["dispense_volume"] + if dispense_z is None: + dispense_z = pt_defaults["dispense_z"] + if aspirate_z is None: + aspirate_z = pt_defaults["aspirate_z"] + if secondary_z is None: + secondary_z = pt_defaults["aspirate_z"] + if final_secondary_z is None: + final_secondary_z = pt_defaults["aspirate_z"] + return (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) + + @classmethod + def _validate_wash_core_params( + cls, + cycles: int, + buffer: Buffer, + dispense_volume: float, + dispense_flow_rate: int, + dispense_x: int, + dispense_y: int, + aspirate_travel_rate: int, + aspirate_x: int, + aspirate_y: int, + pre_dispense_flow_rate: int, + aspirate_delay_ms: int, + wash_format: Literal["Plate", "Sector"], + sector_mask: int, + ) -> None: + """Validate core wash dispense/aspirate parameters.""" + validate_cycles(cycles) + if dispense_volume <= 0: + raise ValueError(f"dispense_volume must be positive, got {dispense_volume}") + validate_buffer(buffer) + validate_flow_rate(dispense_flow_rate) + cls._validate_manifold_xy(dispense_x, dispense_y, "Wash dispense") + validate_travel_rate(aspirate_travel_rate) + cls._validate_manifold_xy(aspirate_x, aspirate_y, "Wash aspirate") + if wash_format not in ("Plate", "Sector"): + raise ValueError(f"wash_format must be 'Plate' or 'Sector', got '{wash_format}'") + if not 0 <= sector_mask <= 0xFFFF: + raise ValueError(f"sector_mask must be 0x0000-0xFFFF, got 0x{sector_mask:04X}") + validate_flow_rate(pre_dispense_flow_rate) + validate_delay_ms(aspirate_delay_ms) + + @classmethod + def _validate_wash_final_and_extras( + cls, + final_aspirate_x: int, + final_aspirate_y: int, + final_aspirate_delay_ms: int, + pre_dispense_volume: float, + vacuum_delay_volume: float, + soak_duration: int, + shake_duration: int, + shake_intensity: Intensity, + ) -> None: + """Validate final-aspirate, pre-dispense, soak/shake parameters.""" + cls._validate_manifold_xy(final_aspirate_x, final_aspirate_y, "Final aspirate") + validate_delay_ms(final_aspirate_delay_ms) + if pre_dispense_volume != 0 and not 25 <= pre_dispense_volume <= 3000: + raise ValueError( + f"Wash pre-dispense volume must be 0 (disabled) or 25-3000 uL, got {pre_dispense_volume}" + ) + if not 0 <= vacuum_delay_volume <= 3000: + raise ValueError(f"Wash vacuum delay volume must be 0-3000 uL, got {vacuum_delay_volume}") + if not 0 <= soak_duration <= 3599: + raise ValueError(f"Wash soak duration must be 0-3599 seconds, got {soak_duration}") + if not 0 <= shake_duration <= 3599: + raise ValueError(f"Wash shake duration must be 0-3599 seconds, got {shake_duration}") + validate_intensity(shake_intensity) + + @classmethod + def _validate_wash_secondary_aspirates( + cls, + secondary_aspirate: bool, + secondary_x: int, + secondary_y: int, + final_secondary_aspirate: bool, + final_secondary_x: int, + final_secondary_y: int, + ) -> None: + """Validate secondary and final-secondary aspirate offsets.""" + if secondary_aspirate: + cls._validate_manifold_xy(secondary_x, secondary_y, "Secondary") + if final_secondary_aspirate: + cls._validate_manifold_xy(final_secondary_x, final_secondary_y, "Final secondary") + + @staticmethod + def _validate_wash_optional_features( + bottom_wash: bool, + bottom_wash_volume: float, + bottom_wash_flow_rate: int, + pre_dispense_between_cycles_volume: float, + pre_dispense_between_cycles_flow_rate: int, + ) -> None: + """Validate bottom wash and mid-cycle pre-dispense.""" + if bottom_wash: + if not 25 <= bottom_wash_volume <= 3000: + raise ValueError(f"Bottom wash volume must be 25-3000 uL, got {bottom_wash_volume}") + validate_flow_rate(bottom_wash_flow_rate) + if pre_dispense_between_cycles_volume != 0: + if not 25 <= pre_dispense_between_cycles_volume <= 3000: + raise ValueError( + f"Pre-dispense between cycles volume must be 0 (disabled) or " + f"25-3000 uL, got {pre_dispense_between_cycles_volume}" + ) + validate_flow_rate(pre_dispense_between_cycles_flow_rate) + + def _validate_wash_params( + self, + plate: Plate, + cycles: int, + buffer: Buffer, + dispense_volume: float | None, + dispense_flow_rate: int, + dispense_x: int, + dispense_y: int, + dispense_z: int | None, + aspirate_travel_rate: int, + aspirate_z: int | None, + aspirate_x: int, + aspirate_y: int, + pre_dispense_flow_rate: int, + aspirate_delay_ms: int, + final_aspirate_x: int, + final_aspirate_y: int, + final_aspirate_delay_ms: int, + pre_dispense_volume: float, + vacuum_delay_volume: float, + soak_duration: int, + shake_duration: int, + shake_intensity: Intensity, + secondary_aspirate: bool, + secondary_z: int | None, + secondary_x: int, + secondary_y: int, + final_secondary_aspirate: bool, + final_secondary_z: int | None, + final_secondary_x: int, + final_secondary_y: int, + bottom_wash: bool, + bottom_wash_volume: float, + bottom_wash_flow_rate: int, + pre_dispense_between_cycles_volume: float, + pre_dispense_between_cycles_flow_rate: int, + wash_format: Literal["Plate", "Sector"], + sector_mask: int, + ) -> tuple[float, int, int, int, int]: + """Validate wash parameters and resolve plate-type defaults. + + Returns: + (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) + """ + ( + dispense_volume, + dispense_z, + aspirate_z, + secondary_z, + final_secondary_z, + ) = self._resolve_wash_defaults( + plate, + dispense_volume, + dispense_z, + aspirate_z, + secondary_z, + final_secondary_z, + ) + self._validate_wash_core_params( + cycles, + buffer, + dispense_volume, + dispense_flow_rate, + dispense_x, + dispense_y, + aspirate_travel_rate, + aspirate_x, + aspirate_y, + pre_dispense_flow_rate, + aspirate_delay_ms, + wash_format, + sector_mask, + ) + self._validate_wash_final_and_extras( + final_aspirate_x, + final_aspirate_y, + final_aspirate_delay_ms, + pre_dispense_volume, + vacuum_delay_volume, + soak_duration, + shake_duration, + shake_intensity, + ) + self._validate_wash_secondary_aspirates( + secondary_aspirate, + secondary_x, + secondary_y, + final_secondary_aspirate, + final_secondary_x, + final_secondary_y, + ) + self._validate_wash_optional_features( + bottom_wash, + bottom_wash_volume, + bottom_wash_flow_rate, + pre_dispense_between_cycles_volume, + pre_dispense_between_cycles_flow_rate, + ) + return (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) + + async def manifold_aspirate( + self, + plate: Plate, + vacuum_filtration: bool = False, + travel_rate: TravelRate = "3", + delay: float = 0.0, + vacuum_time: float = 30.0, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int | None = None, + secondary_aspirate: bool = False, + secondary_x: int = 0, + secondary_y: int = 0, + secondary_z: int | None = None, + ) -> None: + """Aspirate liquid from all wells via the wash manifold. + + Two modes based on vacuum_filtration: + - Normal (vacuum_filtration=False): Uses travel_rate and delay. + - Vacuum filtration (vacuum_filtration=True): Uses vacuum_time. + Travel rate is ignored (greyed out in GUI). + + Args: + plate: PLR Plate resource. + vacuum_filtration: Enable vacuum filtration mode. + travel_rate: Head travel rate. Normal: "1"-"5". + Cell wash: "1 CW", "2 CW", "3 CW", "4 CW", "6 CW". + Ignored when vacuum_filtration=True. + delay: Post-aspirate delay in seconds (0-5). Only used when + vacuum_filtration=False. Wire resolution: 1 ms. + vacuum_time: Vacuum filtration time in seconds (5-999). Only used when + vacuum_filtration=True. + offset_x: X offset in steps (-60 to +60). + offset_y: Y offset in steps (-40 to +40). + offset_z: Z offset in steps (1-210). Default None (plate-type-aware: + 29 for 96-well, 22 for 384-well, etc.). + secondary_aspirate: Enable secondary aspirate (perform a second aspirate + at a different position). Not available for 1536-well plates. + secondary_x: Secondary aspirate X offset (-60 to +60). + secondary_y: Secondary aspirate Y offset (-40 to +40). + secondary_z: Secondary aspirate Z offset (1-210). Default None + (plate-type-aware, same as offset_z default). + + Raises: + ValueError: If parameters are invalid. + """ + # Convert PLR units (seconds) to wire units: seconds → milliseconds, seconds → integer seconds + delay_ms = round(delay * 1000) + vacuum_time_sec = round(vacuum_time) + + offset_z, secondary_z, time_value, rate_byte = self._validate_aspirate_params( + plate=plate, + vacuum_filtration=vacuum_filtration, + travel_rate=travel_rate, + delay_ms=delay_ms, + vacuum_time_sec=vacuum_time_sec, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + secondary_aspirate=secondary_aspirate, + secondary_x=secondary_x, + secondary_y=secondary_y, + secondary_z=secondary_z, + ) + + logger.info( + "Aspirating: vacuum=%s, travel_rate=%s, delay=%.3f s", + vacuum_filtration, + travel_rate, + delay, + ) + + data = self._build_aspirate_command( + plate=plate, + vacuum_filtration=vacuum_filtration, + time_value=time_value, + travel_rate_byte=rate_byte, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + secondary_mode=1 if secondary_aspirate else 0, + secondary_x=secondary_x, + secondary_y=secondary_y, + secondary_z=secondary_z, + ) + framed_command = build_framed_message(command=0xA5, data=data) + async with self._driver.batch(plate): + await self._driver._send_step_command(framed_command) + + async def manifold_dispense( + self, + plate: Plate, + volume: float, + buffer: Buffer = "A", + flow_rate: int = 7, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int | None = None, + pre_dispense_volume: float = 0.0, + pre_dispense_flow_rate: int = 9, + vacuum_delay_volume: float = 0.0, + ) -> None: + """Dispense liquid to all wells via the wash manifold. + + Args: + plate: PLR Plate resource. + volume: Volume to dispense in uL/well. Range: 25-3000 uL (manifold-dependent: + 96-tube manifolds require ≥50, 192/128-tube manifolds allow ≥25). + buffer: Buffer valve selection (A, B, C, D). + flow_rate: Dispense flow rate (1-11, default 7). + Rates 1-2 are for cell wash mode only (96-tube dual-action manifold) + and require vacuum_delay_volume > 0. + Standard range is 3-11. + offset_x: X offset in steps (-60 to +60). + offset_y: Y offset in steps (-40 to +40). + offset_z: Z offset in steps (1-210). Default None (plate-type-aware: + 121 for 96-well, 120 for 384-well, etc.). + pre_dispense_volume: Pre-dispense volume in uL/tube (0 to disable, 25-3000 when enabled). + pre_dispense_flow_rate: Pre-dispense flow rate (3-11, default 9). + vacuum_delay_volume: Delay start of vacuum until volume dispensed in uL/well + (0 to disable, 0-3000 when enabled). Required for cell wash flow rates 1-2. + + Raises: + ValueError: If parameters are invalid. + """ + offset_z = self._validate_dispense_params( + plate=plate, + volume=volume, + buffer=buffer, + flow_rate=flow_rate, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + pre_dispense_volume=pre_dispense_volume, + pre_dispense_flow_rate=pre_dispense_flow_rate, + vacuum_delay_volume=vacuum_delay_volume, + ) + + logger.info( + "Dispensing %.1f uL from buffer %s, flow rate %d", + volume, + buffer, + flow_rate, + ) + + data = self._build_dispense_command( + plate=plate, + volume=volume, + buffer=buffer, + flow_rate=flow_rate, + offset_x=offset_x, + offset_y=offset_y, + offset_z=offset_z, + pre_dispense_volume=pre_dispense_volume, + pre_dispense_flow_rate=pre_dispense_flow_rate, + vacuum_delay_volume=vacuum_delay_volume, + ) + framed_command = build_framed_message(command=0xA6, data=data) + async with self._driver.batch(plate): + await self._driver._send_step_command(framed_command) + + async def manifold_wash( + self, + plate: Plate, + cycles: int = 3, + buffer: Buffer = "A", + dispense_volume: float | None = None, + dispense_flow_rate: int = 7, + dispense_x: int = 0, + dispense_y: int = 0, + dispense_z: int | None = None, + aspirate_travel_rate: int = 3, + aspirate_z: int | None = None, + pre_dispense_flow_rate: int = 9, + aspirate_delay: float = 0.0, + aspirate_x: int = 0, + aspirate_y: int = 0, + final_aspirate: bool = True, + final_aspirate_z: int | None = None, + final_aspirate_x: int = 0, + final_aspirate_y: int = 0, + final_aspirate_delay: float = 0.0, + pre_dispense_volume: float = 0.0, + vacuum_delay_volume: float = 0.0, + soak_duration: int = 0, + shake_duration: int = 0, + shake_intensity: Intensity = "Medium", + secondary_aspirate: bool = False, + secondary_z: int | None = None, + secondary_x: int = 0, + secondary_y: int = 0, + final_secondary_aspirate: bool = False, + final_secondary_z: int | None = None, + final_secondary_x: int = 0, + final_secondary_y: int = 0, + bottom_wash: bool = False, + bottom_wash_volume: float = 0.0, + bottom_wash_flow_rate: int = 5, + pre_dispense_between_cycles_volume: float = 0.0, + pre_dispense_between_cycles_flow_rate: int = 9, + wash_format: Literal["Plate", "Sector"] = "Plate", + sectors: list[int] | None = None, + move_home_first: bool = False, + ) -> None: + """Perform manifold wash cycles. + + Sends a 102-byte MANIFOLD_WASH (0xA4) command that performs repeated + dispense-aspirate cycles. The wire format contains two dispense sections, + two aspirate sections, and a final shake/soak section. + + The wash command supports 4 independent coordinate sets: + - Primary aspirate (aspirate_x/y/z): between-cycle aspirate position + - Primary secondary (secondary_x/y/z): second aspirate position per cycle + - Final aspirate (final_aspirate_x/y/z): aspirate after last cycle + - Final secondary (final_secondary_x/y/z): second position for final aspirate + + Args: + plate: PLR Plate resource. + cycles: Number of wash cycles (1-250). Default 3. + Encoded at header byte [6]. + buffer: Buffer valve selection (A, B, C, D). Default A. + dispense_volume: Volume to dispense per cycle in uL. Default None + (plate-type-aware: 300 for 96-well, 100 for others). + dispense_flow_rate: Flow rate for dispensing (1-9). Default 7. + dispense_x: Dispense X offset in steps (-60 to +60). Default 0. + dispense_y: Dispense Y offset in steps (-40 to +40). Default 0. + dispense_z: Z offset for dispense in 0.1mm units (1-210). Default None + (plate-type-aware: 121 for 96-well, 120 for 384-well, etc.). + aspirate_travel_rate: Travel rate for aspiration (1-9). Default 3. + aspirate_z: Z offset for aspirate in 0.1mm units (1-210). Default None + (plate-type-aware: 29 for 96-well, 22 for 384-well, etc.). + pre_dispense_flow_rate: Pre-dispense flow rate (3-11). Default 9. + Controls how fast the pre-dispense is delivered. + aspirate_delay: Post-aspirate delay in seconds (0-65.535). Default 0. + Wire resolution: 1 ms. + aspirate_x: Aspirate X offset in steps (-60 to +60). Default 0. + aspirate_y: Aspirate Y offset in steps (-40 to +40). Default 0. + final_aspirate: Enable final aspirate after last cycle. Default True. + Encoded in header config flags byte [2]. + final_aspirate_z: Z offset for final aspirate (1-210). Default None + (inherits from aspirate_z). Independent from primary aspirate Z. + final_aspirate_x: X offset for final aspirate (-60 to +60). Default 0. + final_aspirate_y: Y offset for final aspirate (-40 to +40). Default 0. + final_aspirate_delay: Post-aspirate delay for final aspirate in + seconds (0-65.535). Default 0. Wire resolution: 1 ms. + pre_dispense_volume: Pre-dispense volume in uL/tube (0 to disable, + 25-3000 when enabled). Default 0.0. + vacuum_delay_volume: Vacuum delay volume in uL/well (0 to disable, + 0-3000 when enabled). Cell wash operations only. Default 0.0. + soak_duration: Soak duration in seconds (0 to disable, 0-3599). Default 0. + shake_duration: Shake duration in seconds (0 to disable, 0-3599). Default 0. + shake_intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). + Default "Medium". + secondary_aspirate: Enable secondary aspirate for primary (between-cycle) + aspirate. Default False. + secondary_z: Z offset for secondary aspirate in 0.1mm units (1-210). + Default None (plate-type-aware, same as aspirate_z default). + secondary_x: Secondary aspirate X offset (-60 to +60). Default 0. + secondary_y: Secondary aspirate Y offset (-40 to +40). Default 0. + final_secondary_aspirate: Enable secondary aspirate for final aspirate. + Default False. + final_secondary_z: Z offset for final secondary aspirate (1-210). + Default None (plate-type-aware, same as aspirate_z default). + final_secondary_x: X offset for final secondary aspirate (-60 to +60). + Default 0. + final_secondary_y: Y offset for final secondary aspirate (-40 to +40). + Default 0. + bottom_wash: Enable bottom wash. Default False. Encoded in header[1]. + bottom_wash_volume: Bottom wash volume in uL (25-3000). Default 0.0. + bottom_wash_flow_rate: Bottom wash flow rate (3-11). Default 5. + pre_dispense_between_cycles_volume: Pre-dispense volume between wash + cycles in uL (0 to disable, 25-3000 when enabled). Default 0.0. + pre_dispense_between_cycles_flow_rate: Flow rate for pre-dispense between + cycles (3-11). Default 9. + wash_format: Wash format ("Plate" or "Sector"). Default "Plate". + Encoded at header[3]: Plate=0x00, Sector=0x01. + 384-well plates typically use "Sector" for quadrant-based washing. + sectors: List of quadrant numbers to wash (1-4). Default None (all 4). + Example: ``sectors=[1, 2]`` washes quadrants 1 and 2. + Only used when wash_format="Sector". + move_home_first: Move carrier to home position before shake/soak. + Default False. Same as in standalone shake interface. + Encoded at wire [87] (shake/soak section byte 0). + + Raises: + ValueError: If parameters are invalid. + """ + # Convert PLR units (seconds) to wire units (ms) + aspirate_delay_ms = round(aspirate_delay * 1000) + final_aspirate_delay_ms = round(final_aspirate_delay * 1000) + + # Convert sectors list to bitmask + if sectors is not None: + sector_mask = 0 + for q in sectors: + if not 1 <= q <= 4: + raise ValueError(f"Sector/quadrant must be 1-4, got {q}") + sector_mask |= 1 << (q - 1) + else: + sector_mask = 0x0F + + ( + dispense_volume, + dispense_z, + aspirate_z, + secondary_z, + final_secondary_z, + ) = self._validate_wash_params( + plate=plate, + cycles=cycles, + buffer=buffer, + dispense_volume=dispense_volume, + dispense_flow_rate=dispense_flow_rate, + dispense_x=dispense_x, + dispense_y=dispense_y, + dispense_z=dispense_z, + aspirate_travel_rate=aspirate_travel_rate, + aspirate_z=aspirate_z, + aspirate_x=aspirate_x, + aspirate_y=aspirate_y, + pre_dispense_flow_rate=pre_dispense_flow_rate, + aspirate_delay_ms=aspirate_delay_ms, + final_aspirate_x=final_aspirate_x, + final_aspirate_y=final_aspirate_y, + final_aspirate_delay_ms=final_aspirate_delay_ms, + pre_dispense_volume=pre_dispense_volume, + vacuum_delay_volume=vacuum_delay_volume, + soak_duration=soak_duration, + shake_duration=shake_duration, + shake_intensity=shake_intensity, + secondary_aspirate=secondary_aspirate, + secondary_z=secondary_z, + secondary_x=secondary_x, + secondary_y=secondary_y, + final_secondary_aspirate=final_secondary_aspirate, + final_secondary_z=final_secondary_z, + final_secondary_x=final_secondary_x, + final_secondary_y=final_secondary_y, + bottom_wash=bottom_wash, + bottom_wash_volume=bottom_wash_volume, + bottom_wash_flow_rate=bottom_wash_flow_rate, + pre_dispense_between_cycles_volume=pre_dispense_between_cycles_volume, + pre_dispense_between_cycles_flow_rate=pre_dispense_between_cycles_flow_rate, + wash_format=wash_format, + sector_mask=sector_mask, + ) + + logger.info( + "Manifold wash: %d cycles, %.1f uL, buffer %s, flow %d, " + "disp_xy=(%d,%d), z_disp=%d, z_asp=%d, pre_disp_flow=%d, " + "asp_delay=%.3f s, asp_xy=(%d,%d), final_asp=%s, " + "pre_disp=%.1f, vac_delay=%.1f, soak=%d, shake=%d/%s, " + "sec_asp=%s, sec_z=%d, sec_xy=(%d,%d), " + "btm_wash=%s/%.1f/%d, midcyc=%.1f/%d", + cycles, + dispense_volume, + buffer, + dispense_flow_rate, + dispense_x, + dispense_y, + dispense_z, + aspirate_z, + pre_dispense_flow_rate, + aspirate_delay, + aspirate_x, + aspirate_y, + final_aspirate, + pre_dispense_volume, + vacuum_delay_volume, + soak_duration, + shake_duration, + shake_intensity, + secondary_aspirate, + secondary_z, + secondary_x, + secondary_y, + bottom_wash, + bottom_wash_volume, + bottom_wash_flow_rate, + pre_dispense_between_cycles_volume, + pre_dispense_between_cycles_flow_rate, + ) + + data = self._build_wash_composite_command( + plate=plate, + cycles=cycles, + buffer=buffer, + dispense_volume=dispense_volume, + dispense_flow_rate=dispense_flow_rate, + dispense_x=dispense_x, + dispense_y=dispense_y, + dispense_z=dispense_z, + aspirate_travel_rate=aspirate_travel_rate, + aspirate_z=aspirate_z, + pre_dispense_flow_rate=pre_dispense_flow_rate, + aspirate_delay_ms=aspirate_delay_ms, + aspirate_x=aspirate_x, + aspirate_y=aspirate_y, + final_aspirate=final_aspirate, + final_aspirate_z=final_aspirate_z, + final_aspirate_x=final_aspirate_x, + final_aspirate_y=final_aspirate_y, + final_aspirate_delay_ms=final_aspirate_delay_ms, + pre_dispense_volume=pre_dispense_volume, + vacuum_delay_volume=vacuum_delay_volume, + soak_duration=soak_duration, + shake_duration=shake_duration, + shake_intensity=shake_intensity, + secondary_aspirate=secondary_aspirate, + secondary_z=secondary_z, + secondary_x=secondary_x, + secondary_y=secondary_y, + final_secondary_aspirate=final_secondary_aspirate, + final_secondary_z=final_secondary_z, + final_secondary_x=final_secondary_x, + final_secondary_y=final_secondary_y, + bottom_wash=bottom_wash, + bottom_wash_volume=bottom_wash_volume, + bottom_wash_flow_rate=bottom_wash_flow_rate, + pre_dispense_between_cycles_volume=pre_dispense_between_cycles_volume, + pre_dispense_between_cycles_flow_rate=pre_dispense_between_cycles_flow_rate, + wash_format=wash_format, + sector_mask=sector_mask, + move_home_first=move_home_first, + ) + + framed_command = build_framed_message(command=0xA4, data=data) + # Dynamic timeout: base per cycle + shake + soak + buffer + # Each cycle takes ~10-30s depending on volume/flow/plate type. + # Use 60s per cycle as generous safety margin to avoid false timeouts. + wash_timeout = (cycles * 60) + shake_duration + soak_duration + 120 + async with self._driver.batch(plate): + await self._driver._send_step_command(framed_command, timeout=wash_timeout) + + async def manifold_prime( + self, + plate: Plate, + volume: float, + buffer: Buffer = "A", + flow_rate: int = 9, + low_flow_volume: float = 5000.0, + submerge_duration: float = 0.0, + ) -> None: + """Prime the manifold fluid lines. + + Fills the wash manifold tubing with liquid from the specified buffer. + This is typically done at the start of a protocol to ensure the lines + are filled and ready for dispensing. + + Args: + plate: PLR Plate resource. + volume: Prime volume in uL. Range: 5000-999000 uL. + Wire resolution: 1000 uL (1 mL). + buffer: Buffer valve selection (A, B, C, D). + flow_rate: Flow rate (3-11, default 9). + low_flow_volume: Low flow path volume in uL (5000-999000, default 5000). + Set to 0 to disable. Wire resolution: 1000 uL (1 mL). + submerge_duration: Submerge duration in seconds (0 to disable, 60-86340 when + enabled). Wire resolution: 60 s (1 minute). + + Raises: + ValueError: If parameters are invalid. + """ + # Validate in PLR units + if not 5000 <= volume <= 999000: + raise ValueError(f"Washer prime volume must be 5000-999000 uL, got {volume}") + validate_buffer(buffer) + if not 3 <= flow_rate <= 11: + raise ValueError(f"Washer prime flow rate must be 3-11, got {flow_rate}") + if low_flow_volume != 0 and not 5000 <= low_flow_volume <= 999000: + raise ValueError( + f"Low flow path volume must be 0 (disabled) or 5000-999000 uL, got {low_flow_volume}" + ) + if submerge_duration != 0 and not 60 <= submerge_duration <= 86340: + raise ValueError( + f"Submerge duration must be 0 (disabled) or 60-86340 seconds, got {submerge_duration}" + ) + if submerge_duration % 60 != 0: + raise ValueError( + f"Submerge duration must be a multiple of 60 seconds (device resolution is 1 minute), " + f"got {submerge_duration}" + ) + + # Convert to wire units: uL → mL, seconds → minutes + volume_ml = round(volume / 1000) + low_flow_volume_ml = round(low_flow_volume / 1000) + submerge_duration_min = round(submerge_duration / 60) + + low_flow_enabled = low_flow_volume > 0 + submerge_enabled = submerge_duration > 0 + + logger.info( + "Manifold prime: %.1f uL from buffer %s, flow rate %d, low_flow=%s/%.0f uL, " + "submerge=%s/%.0f s", + volume, + buffer, + flow_rate, + "enabled" if low_flow_enabled else "disabled", + low_flow_volume, + "enabled" if submerge_enabled else "disabled", + submerge_duration, + ) + + data = self._build_manifold_prime_command( + plate=plate, + buffer=buffer, + volume_ml=volume_ml, + flow_rate=flow_rate, + low_flow_volume_ml=low_flow_volume_ml, + low_flow_enabled=low_flow_enabled, + submerge_enabled=submerge_enabled, + submerge_duration_min=submerge_duration_min, + ) + framed_command = build_framed_message(command=0xA7, data=data) + # Timeout: base time for priming + submerge duration + buffer + prime_timeout = self._driver.timeout + submerge_duration + 30 + async with self._driver.batch(plate): + await self._driver._send_step_command(framed_command, timeout=prime_timeout) + + async def manifold_auto_clean( + self, + plate: Plate, + buffer: Buffer = "A", + duration: float = 60.0, + ) -> None: + """Run a manifold auto-clean cycle. + + Args: + plate: PLR Plate resource. + buffer: Buffer valve to use (A, B, C, or D). + duration: Cleaning duration in seconds (60-14340, i.e. up to 3h59m). + Wire resolution: 60 s (1 minute). + + Raises: + ValueError: If parameters are invalid. + """ + validate_buffer(buffer) + if not 60 <= duration <= 14340: + raise ValueError(f"AutoClean duration must be 60-14340 seconds, got {duration}") + if duration % 60 != 0: + raise ValueError( + f"AutoClean duration must be a multiple of 60 seconds (device resolution is 1 minute), " + f"got {duration}" + ) + + # Convert to wire units: seconds → minutes + duration_min = round(duration / 60) + + logger.info("Auto-clean: buffer %s, duration %.0f s", buffer, duration) + + data = self._build_auto_clean_command( + plate=plate, + buffer=buffer, + duration_min=duration_min, + ) + framed_command = build_framed_message(command=0xA8, data=data) + auto_clean_timeout = max(120.0, duration + 30.0) + async with self._driver.batch(plate): + await self._driver._send_step_command(framed_command, timeout=auto_clean_timeout) + + # ========================================================================= + # COMMAND BUILDERS + # ========================================================================= + + def _build_wash_composite_command( + self, + plate: Plate, + cycles: int = 3, + buffer: Buffer = "A", + dispense_volume: float | None = None, + dispense_flow_rate: int = 7, + dispense_x: int = 0, + dispense_y: int = 0, + dispense_z: int | None = None, + aspirate_travel_rate: int = 3, + aspirate_z: int | None = None, + pre_dispense_flow_rate: int = 9, + aspirate_delay_ms: int = 0, + aspirate_x: int = 0, + aspirate_y: int = 0, + final_aspirate: bool = True, + final_aspirate_z: int | None = None, + final_aspirate_x: int = 0, + final_aspirate_y: int = 0, + final_aspirate_delay_ms: int = 0, + pre_dispense_volume: float = 0.0, + vacuum_delay_volume: float = 0.0, + soak_duration: int = 0, + shake_duration: int = 0, + shake_intensity: Intensity = "Medium", + secondary_aspirate: bool = False, + secondary_z: int | None = None, + secondary_x: int = 0, + secondary_y: int = 0, + final_secondary_aspirate: bool = False, + final_secondary_z: int | None = None, + final_secondary_x: int = 0, + final_secondary_y: int = 0, + bottom_wash: bool = False, + bottom_wash_volume: float = 0.0, + bottom_wash_flow_rate: int = 5, + pre_dispense_between_cycles_volume: float = 0.0, + pre_dispense_between_cycles_flow_rate: int = 9, + wash_format: Literal["Plate", "Sector"] = "Plate", + sector_mask: int = 0x0F, + move_home_first: bool = False, + ) -> bytes: + """Build 102-byte MANIFOLD_WASH (0xA4) command payload. + + Structure: header(7) + dispense1(22) + final_aspirate(20) + primary_aspirate(19) + + dispense2(19) + shake_soak(15) = 102 bytes. + + Header [0-6]: + [0] plate_type (plate_type.value) + [1] bottom_wash enable + [2] config flags -- final_aspirate + [3] wash_format -- 0=Plate, 1=Sector + [4-5] sector_mask as 16-bit LE + [6] wash cycles count + + Four coordinate sets for aspirate positions: + - Primary: aspirate_x/y/z (between-cycle aspirate, wire [49-67]) + - Primary secondary: secondary_x/y/z (wire [55-61]) + - Final: final_aspirate_x/y/z (post-cycle aspirate, wire [29-48]) + - Final secondary: final_secondary_x/y/z (wire [37-41]) + + Returns: + 102-byte command payload. + """ + # Resolve plate-type defaults + ( + dispense_volume, + dispense_z, + aspirate_z, + secondary_z, + final_secondary_z, + ) = self._resolve_wash_defaults( + plate, dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z + ) + + # Derived values + buffer_char = ord(buffer.upper()) + disp_vol = int(dispense_volume) + final_asp_z = final_aspirate_z if final_aspirate_z is not None else aspirate_z + pre_disp = int(pre_dispense_volume) if pre_dispense_volume > 0 else 0 + vac_delay = int(vacuum_delay_volume) if vacuum_delay_volume > 0 else 0 + intensity_byte = INTENSITY_TO_BYTE.get(shake_intensity, 0x03) if shake_duration > 0 else 0x00 + + # Secondary aspirate offsets (0 when disabled) + sec_x = secondary_x if secondary_aspirate else 0 + sec_y = secondary_y if secondary_aspirate else 0 + final_sec_x = final_secondary_x if final_secondary_aspirate else 0 + final_sec_y = final_secondary_y if final_secondary_aspirate else 0 + final_sec_z = final_secondary_z if final_secondary_aspirate else final_asp_z + + # Bottom wash: Dispense1 gets bottom wash params when enabled, else mirrors main + bw_vol = int(bottom_wash_volume) if bottom_wash else disp_vol + bw_flow = bottom_wash_flow_rate if bottom_wash else dispense_flow_rate + + # Pre-dispense between cycles: override or fall back to main pre-dispense + if pre_dispense_between_cycles_volume > 0: + midcyc_vol = int(pre_dispense_between_cycles_volume) + midcyc_flow = pre_dispense_between_cycles_flow_rate + else: + midcyc_vol = pre_disp + midcyc_flow = pre_dispense_flow_rate + + w = Writer() + + # --- Header [0-6] (7 bytes) --- + w.u8(plate_to_wire_byte(plate)) # [0] Plate type + w.u8(0x01 if bottom_wash else 0x00) # [1] Bottom wash enable + w.u8(0x01 if final_aspirate else 0x00) # [2] Config flags + w.u8({"Plate": 0x00, "Sector": 0x01}[wash_format]) # [3] Wash format + w.u16(sector_mask) # [4-5] Sector mask (LE) + w.u8(cycles) # [6] Wash cycles + + # --- Dispense section 1 [7-28] (22 bytes) — bottom wash or mirror of main --- + w.u8(buffer_char) # [7] Buffer (ASCII) + w.u16(bw_vol) # [8-9] Volume (LE) + w.u8(bw_flow) # [10] Flow rate + w.i8(dispense_x) # [11] Offset X + w.i8(dispense_y) # [12] Offset Y + w.u16(dispense_z) # [13-14] Dispense Z (LE) + w.u16(pre_disp) # [15-16] Pre-dispense vol (LE) + w.u8(pre_dispense_flow_rate) # [17] Pre-dispense flow rate + w.u16(vac_delay) # [18-19] Vacuum delay vol (LE) + w.raw_bytes(b"\x00" * 7) # [20-26] Padding + w.u16(final_aspirate_delay_ms) # [27-28] Final asp delay (LE) + + # --- Final aspirate section [29-48] (20 bytes) --- + w.u8(aspirate_travel_rate) # [29] Travel rate + w.u16(0x0000) # [30-31] Delay (always 0 here) + w.u16(final_asp_z) # [32-33] Final aspirate Z (LE) + w.u8(0x01 if final_secondary_aspirate else 0x00) # [34] Final secondary mode + w.i8(final_aspirate_x) # [35] Final aspirate X + w.i8(final_aspirate_y) # [36] Final aspirate Y + w.u16(final_sec_z) # [37-38] Final secondary Z (LE) + w.u8(0x00) # [39] Reserved + w.i8(final_sec_x) # [40] Final secondary X + w.i8(final_sec_y) # [41] Final secondary Y + w.raw_bytes(b"\x00" * 5) # [42-46] Reserved + w.u8(0x00) # [47] vac_filt (always 0 in wash) + # aspirate_delay_ms split: low byte here, high byte starts next section + w.u8(aspirate_delay_ms & 0xFF) # [48] asp delay low + + # --- Primary aspirate section [49-67] (19 bytes) --- + w.u8((aspirate_delay_ms >> 8) & 0xFF) # [49] asp delay high + w.u8(aspirate_travel_rate) # [50] Travel rate + w.i8(aspirate_x) # [51] Aspirate X + w.i8(aspirate_y) # [52] Aspirate Y + w.u16(aspirate_z) # [53-54] Aspirate Z (LE) + w.u8(0x01 if secondary_aspirate else 0x00) # [55] Secondary mode + w.i8(sec_x) # [56] Secondary X + w.i8(sec_y) # [57] Secondary Y + w.u16(secondary_z) # [58-59] Secondary Z (LE) + w.raw_bytes(b"\x00" * 8) # [60-67] Reserved + + # --- Dispense section 2 [68-86] (19 bytes) — main dispense --- + w.u8(buffer_char) # [68] Buffer (ASCII) + w.u16(disp_vol) # [69-70] Volume (LE) + w.u8(dispense_flow_rate) # [71] Flow rate + w.i8(dispense_x) # [72] Offset X + w.i8(dispense_y) # [73] Offset Y + w.u16(dispense_z) # [74-75] Dispense Z (LE) + w.u16(midcyc_vol) # [76-77] Mid-cycle vol (LE) + w.u8(midcyc_flow) # [78] Mid-cycle flow rate + w.u16(vac_delay) # [79-80] Vacuum delay vol (LE) + w.raw_bytes(b"\x00" * 6) # [81-86] Padding + + # --- Shake/soak section [87-101] (15 bytes) --- + w.u8(0x01 if move_home_first else 0x00) # [87] move_home_first + w.u16(shake_duration) # [88-89] Shake duration (LE) + w.u8(intensity_byte if shake_duration > 0 else 0x03) # [90] Intensity + w.u8(0x00) # [91] Shake type (always 0) + w.u16(soak_duration) # [92-93] Soak duration (LE) + w.raw_bytes(b"\x00" * 4) # [94-97] Padding + w.raw_bytes(b"\x00" * 4) # [98-101] Trailing padding + + data = w.finish() + assert len(data) == 102, f"Wash command should be 102 bytes, got {len(data)}" + + logger.debug("Wash command data (%d bytes): %s", len(data), data.hex()) + return data + + def _build_aspirate_command( + self, + plate: Plate, + vacuum_filtration: bool = False, + time_value: int = 0, + travel_rate_byte: int = 3, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 30, + secondary_mode: int = 0, + secondary_x: int = 0, + secondary_y: int = 0, + secondary_z: int = 30, + ) -> bytes: + """Build aspirate command bytes. + + Wire format (22 bytes): + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1] vacuum_filtration: 0 or 1 + [2-3] time_value: ushort LE. delay_ms when normal, vacuum_time_sec when vacuum. + [4] travel_rate: byte from lookup table + [5] x_offset: signed byte + [6] y_offset: signed byte + [7-8] z_offset: short LE + [9] secondary_mode: byte (0=None, 1=enabled) + [10] secondary_x: signed byte + [11] secondary_y: signed byte + [12-13] secondary_z: short LE + [14-15] reserved: 0x0000 + [16-17] unknown: 0xFF0F (possibly column mask?) + [18-21] padding: 4 bytes 0x00 + + Args: + vacuum_filtration: Enable vacuum filtration. + time_value: Delay in ms (normal mode) or time in seconds (vacuum mode). + travel_rate_byte: Pre-encoded travel rate byte value. + offset_x: X offset (signed byte). + offset_y: Y offset (signed byte). + offset_z: Z offset (unsigned short). + secondary_mode: Secondary aspirate mode byte (0=None, 1=enabled). + secondary_x: Secondary X offset (signed byte). + secondary_y: Secondary Y offset (signed byte). + secondary_z: Secondary Z offset (unsigned short). + + Returns: + Command bytes (22 bytes). + """ + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(1 if vacuum_filtration else 0) # [1] Vacuum filtration + .u16(time_value) # [2-3] Time/delay (LE) + .u8(travel_rate_byte & 0xFF) # [4] Travel rate + .i8(offset_x) # [5] X offset + .i8(offset_y) # [6] Y offset + .u16(offset_z) # [7-8] Z offset (LE) + .u8(secondary_mode & 0xFF) # [9] Secondary mode + .i8(secondary_x) # [10] Secondary X + .i8(secondary_y) # [11] Secondary Y + .u16(secondary_z) # [12-13] Secondary Z (LE) + .raw_bytes(b'\x00' * 2) # [14-15] Reserved + .raw_bytes(b'\xff\x0f') # [16-17] Unknown, possibly column mask + .raw_bytes(b'\x00' * 4) # [18-21] Padding + .finish() + ) # fmt: skip + + def _build_dispense_command( + self, + plate: Plate, + volume: float, + buffer: Buffer, + flow_rate: int, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 121, + pre_dispense_volume: float = 0.0, + pre_dispense_flow_rate: int = 9, + vacuum_delay_volume: float = 0.0, + ) -> bytes: + """Build manifold dispense command bytes. + + Protocol format for manifold dispense: + Wire format: 20 bytes (19 + plate type prefix) + + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) + [2-3] Volume: 2 bytes, LE, in uL (25-3000) + [4] Flow rate: 1-11 (1-2 = cell wash, requires vacuum delay) + [5] Offset X: signed byte (-60..60) + [6] Offset Y: signed byte (-40..40) + [7-8] Offset Z: 2 bytes, LE (1-210) + [9-10] Pre-dispense volume: 2 bytes, LE (0 if disabled, 25-3000 when enabled) + [11] Pre-dispense flow rate: 3-11 + [12-13] Vacuum delay volume: 2 bytes, LE (0 if disabled, 0-3000) + [14-19] Padding: 6 bytes (0x00) + + Note: Pre-dispense is enabled when pre_dispense_volume > 0. + Vacuum delay is enabled when vacuum_delay_volume > 0. + + Args: + volume: Dispense volume in uL. + buffer: Buffer valve (A, B, C, D). + flow_rate: Flow rate (1-11; 1-2 = cell wash, requires vacuum delay). + offset_x: X offset (signed, steps, -60..60). + offset_y: Y offset (signed, steps, -40..40). + offset_z: Z offset (steps, 1-210). + pre_dispense_volume: Pre-dispense volume in uL (0 to disable). + pre_dispense_flow_rate: Pre-dispense flow rate (3-11). + vacuum_delay_volume: Vacuum delay volume in uL (0 to disable). + + Returns: + Command bytes (20 bytes). + """ + pre_disp_vol = int(pre_dispense_volume) if pre_dispense_volume > 0 else 0 + vac_delay = int(vacuum_delay_volume) if vacuum_delay_volume > 0 else 0 + + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(ord(buffer.upper())) # [1] Buffer (ASCII) + .u16(int(volume)) # [2-3] Volume (LE) + .u8(flow_rate) # [4] Flow rate + .i8(offset_x) # [5] X offset + .i8(offset_y) # [6] Y offset + .u16(offset_z) # [7-8] Z offset (LE) + .u16(pre_disp_vol) # [9-10] Pre-dispense volume (LE) + .u8(pre_dispense_flow_rate) # [11] Pre-dispense flow rate + .u16(vac_delay) # [12-13] Vacuum delay volume (LE) + .raw_bytes(b'\x00' * 6) # [14-19] Padding + .finish() + ) # fmt: skip + + def _build_manifold_prime_command( + self, + plate: Plate, + buffer: Buffer, + volume_ml: float, + flow_rate: int = 9, + low_flow_volume_ml: int = 5, + low_flow_enabled: bool = True, + submerge_enabled: bool = False, + submerge_duration_min: int = 0, + ) -> bytes: + """Build manifold prime command bytes. + + Protocol format for manifold prime (13 bytes): + + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) + [2-3] Volume: 2 bytes, little-endian, in mL + [4] Flow rate: 3-11 + [5-6] Low flow volume: 2 bytes, little-endian (in mL, 0 if disabled) + [7-8] Submerge duration: 2 bytes, little-endian (in minutes, 0 if disabled) + HH:MM encoded as total minutes: hours*60+minutes + [9-12] Padding zeros: 4 bytes + + Args: + buffer: Buffer valve (A, B, C, D). + volume_ml: Prime volume in mL. + flow_rate: Flow rate (3-11, default 9). + low_flow_volume_ml: Low flow volume in mL (default 5). + low_flow_enabled: Enable low flow path (default True). + submerge_enabled: Enable submerge tips after prime (default False). + submerge_duration_min: Submerge duration in minutes (default 0). + + Returns: + Command bytes (13 bytes). + """ + lf_vol = low_flow_volume_ml if (low_flow_enabled and low_flow_volume_ml > 0) else 0 + sub_dur = submerge_duration_min if submerge_enabled else 0 + + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(ord(buffer.upper())) # [1] Buffer (ASCII) + .u16(int(volume_ml)) # [2-3] Volume (LE, mL) + .u8(flow_rate) # [4] Flow rate + .u16(lf_vol) # [5-6] Low flow volume (LE, mL) + .u16(sub_dur) # [7-8] Submerge duration (LE, minutes) + .raw_bytes(b'\x00' * 4) # [9-12] Padding + .finish() + ) # fmt: skip + + def _build_auto_clean_command( + self, + plate: Plate, + buffer: Buffer, + duration_min: int = 1, + ) -> bytes: + """Build auto-clean command bytes. + + Protocol format for auto-clean (8 bytes): + + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) + [2-3] Duration: 2 bytes, little-endian (in minutes) + [4-7] Padding zeros: 4 bytes + + Args: + buffer: Buffer valve (A, B, C, D). + duration_min: Cleaning duration in minutes (1-239). + + Returns: + Command bytes (8 bytes). + """ + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(ord(buffer.upper())) # [1] Buffer (ASCII) + .u16(int(duration_min)) # [2-3] Duration (LE, minutes) + .raw_bytes(b'\x00' * 4) # [4-7] Padding + .finish() + ) # fmt: skip diff --git a/pylabrobot/agilent/biotek/el406/protocol.py b/pylabrobot/agilent/biotek/el406/protocol.py new file mode 100644 index 00000000000..8ec638c0bfc --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/protocol.py @@ -0,0 +1,107 @@ +"""EL406 protocol framing utilities. + +This module contains the protocol framing functions for building +properly formatted messages for the BioTek EL406 plate washer. +""" + +from __future__ import annotations + +from pylabrobot.io.binary import Writer + + +def build_framed_message(command: int, data: bytes = b"") -> bytes: + """Build a properly framed EL406 message. + + Protocol structure: + [0]: 0x01 (start marker) + [1]: 0x02 (version marker) + [2-3]: command (little-endian short) + [4]: 0x01 (constant) + [5-6]: reserved (ushort, typically 0) + [7-8]: data length (ushort, little-endian) + [9-10]: checksum (ushort, little-endian) + ... followed by data bytes + + Checksum is two's complement of sum of header bytes 0-8 + all data bytes. + + Args: + command: 16-bit command code + data: Optional data bytes + + Returns: + Complete framed message with header and checksum + """ + # Build header bytes 0-8 (checksum placeholder filled after) + header_prefix = ( + Writer() + .u8(0x01) # [0] Start marker + .u8(0x02) # [1] Version marker + .u16(command) # [2-3] Command (LE) + .u8(0x01) # [4] Constant + .u16(0x0000) # [5-6] Reserved + .u16(len(data)) # [7-8] Data length (LE) + .finish() + ) # fmt: skip + + # Checksum: two's complement of sum of header bytes 0-8 + all data bytes + checksum_sum = sum(header_prefix) + sum(data) + checksum = (0xFFFF - checksum_sum + 1) & 0xFFFF + + return header_prefix + Writer().u16(checksum).finish() + data + + +def encode_column_mask(columns: list[int] | None) -> bytes: + """Encode list of column indices to 6-byte (48-bit) column mask. + + Each bit represents one column: 0 = skip, 1 = operate on column. + + Args: + columns: List of column indices (0-47) to select, or None for all columns. + If None, returns all 1s (all columns selected). + If empty list, returns all 0s (no columns selected). + + Returns: + 6 bytes representing the 48-bit column mask in little-endian order. + + Raises: + ValueError: If any column index is out of range (not 0-47). + """ + if columns is None: + return bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) + + for col in columns: + if col < 0 or col > 47: + raise ValueError(f"Column index {col} out of range. Must be 0-47.") + + mask = [0] * 6 + for col in columns: + byte_index = col // 8 + bit_index = col % 8 + mask[byte_index] |= 1 << bit_index + + return bytes(mask) + + +def columns_to_column_mask(columns: list[int] | None, plate_wells: int = 96) -> list[int] | None: + """Convert 1-indexed column numbers to 0-indexed column indices. + + Args: + columns: List of column numbers (1-based), or None for all columns. + plate_wells: Plate format (96, 384, 1536). Determines max columns. + + Returns: + List of 0-indexed column indices, or None if columns is None. + + Raises: + ValueError: If column numbers are out of range. + """ + if columns is None: + return None + + max_cols = {96: 12, 384: 24, 1536: 48}.get(plate_wells, 48) + indices = [] + for col in columns: + if col < 1 or col > max_cols: + raise ValueError(f"Column {col} out of range for {plate_wells}-well plate (1-{max_cols}).") + indices.append(col - 1) + return indices diff --git a/pylabrobot/agilent/biotek/el406/shaking_backend.py b/pylabrobot/agilent/biotek/el406/shaking_backend.py new file mode 100644 index 00000000000..e08ab47e132 --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/shaking_backend.py @@ -0,0 +1,184 @@ +"""EL406 shake/soak backend. + +Provides the shake operation and its command builder. +This is a direct port of the legacy EL406ShakeStepsMixin. +""" + +from __future__ import annotations + +import logging +from typing import Literal + +from pylabrobot.capabilities.shaking.backend import ShakerBackend +from pylabrobot.io.binary import Writer +from pylabrobot.resources import Plate + +from .driver import EL406Driver +from .helpers import plate_to_wire_byte +from .protocol import build_framed_message + +INTENSITY_TO_BYTE: dict[str, int] = { + "Variable": 0x01, + "Slow": 0x02, + "Medium": 0x03, + "Fast": 0x04, +} + +logger = logging.getLogger(__name__) + + +Intensity = Literal["Variable", "Slow", "Medium", "Fast"] + + +def validate_intensity(intensity: Intensity) -> None: + if intensity not in {"Slow", "Medium", "Fast", "Variable"}: + raise ValueError( + f"intensity must be one of {sorted({'Slow', 'Medium', 'Fast', 'Variable'})}, got {intensity!r}" + ) + + +class EL406ShakingBackend(ShakerBackend): + """Shaking backend for the BioTek EL406. + + The EL406 shake is a single fire-and-forget command with duration baked in. + It does not support start/stop or plate locking. + """ + + def __init__(self, driver: EL406Driver) -> None: + self._driver = driver + + # -- ShakerBackend interface -- + + async def start_shaking(self, speed: float): + raise NotImplementedError( + "EL406 does not support start/stop shaking. Use shake(plate, duration, ...) directly." + ) + + async def stop_shaking(self): + raise NotImplementedError( + "EL406 does not support start/stop shaking. Use shake(plate, duration, ...) directly." + ) + + @property + def supports_locking(self) -> bool: + return False + + async def lock_plate(self): + raise NotImplementedError("EL406 does not support plate locking.") + + async def unlock_plate(self): + raise NotImplementedError("EL406 does not support plate locking.") + + # -- EL406-specific shake API (moved from legacy EL406ShakeStepsMixin) -- + + MAX_SHAKE_DURATION = 3599 # 59:59 max (mm:ss format, mm max=59) + MAX_SOAK_DURATION = 3599 # 59:59 max (mm:ss format, mm max=59) + + async def shake( + self, + plate: Plate, + duration: int = 0, + intensity: Intensity = "Medium", + soak_duration: int = 0, + move_home_first: bool = True, + ) -> None: + """Shake the plate with optional soak period. + + Durations are in whole seconds (GUI uses mm:ss picker, max 59:59 each). + A duration of 0 disables shake. A soak_duration of 0 disables soak. + + Note: The GUI forces move_home_first=True when total time exceeds 60s + to prevent manifold drip contamination. Our default of True matches this. + + Args: + plate: PLR Plate resource. + duration: Shake duration in seconds (0-3599). 0 to disable shake. + intensity: Shake intensity - "Variable", "Slow" (3.5 Hz), + "Medium" (5 Hz), or "Fast" (8 Hz). + soak_duration: Soak duration in seconds after shaking (0-3599). 0 to disable. + move_home_first: Move carrier to home position before shaking (default True). + + Raises: + ValueError: If parameters are invalid. + """ + if duration < 0 or duration > self.MAX_SHAKE_DURATION: + raise ValueError(f"Invalid duration {duration}. Must be 0-{self.MAX_SHAKE_DURATION}.") + if soak_duration < 0 or soak_duration > self.MAX_SOAK_DURATION: + raise ValueError( + f"Invalid soak_duration {soak_duration}. Must be 0-{self.MAX_SOAK_DURATION}." + ) + if duration == 0 and soak_duration == 0: + raise ValueError("At least one of duration or soak_duration must be > 0.") + validate_intensity(intensity) + + shake_enabled = duration > 0 + + logger.info( + "Shake: %ds, %s intensity, move_home=%s, soak=%ds", + duration, + intensity, + move_home_first, + soak_duration, + ) + + data = self._build_shake_command( + plate=plate, + shake_duration=duration, + soak_duration=soak_duration, + intensity=intensity, + shake_enabled=shake_enabled, + move_home_first=move_home_first, + ) + framed_command = build_framed_message(command=0xA3, data=data) + total_timeout = duration + soak_duration + self._driver.timeout + async with self._driver.batch(plate): + await self._driver._send_step_command(framed_command, timeout=total_timeout) + + # ========================================================================= + # COMMAND BUILDERS + # ========================================================================= + + def _build_shake_command( + self, + plate: Plate, + shake_duration: int = 0, + soak_duration: int = 0, + intensity: Intensity = "Medium", + shake_enabled: bool = True, + move_home_first: bool = True, + ) -> bytes: + """Build shake command bytes. + + Byte structure (12 bytes): + [0] Plate type + [1] move_home_first: 0x00 or 0x01 + [2-3] Shake duration in total seconds (16-bit LE) + [4] Intensity: 0x01=Variable, 0x02=Slow, 0x03=Medium, 0x04=Fast + [5] Reserved: 0x00 + [6-7] Soak duration in total seconds (16-bit LE) + [8-11] Padding (4 bytes) + + Args: + plate: PLR Plate resource. + shake_duration: Shake duration in seconds. + soak_duration: Soak duration in seconds. + intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). + shake_enabled: Whether shake is enabled. When False, shake_duration is not encoded. + move_home_first: Move carrier to home position before shaking (default True). + + Returns: + Command bytes (12 bytes). + """ + shake_total_seconds = int(shake_duration) if shake_enabled else 0 + + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(0x01 if move_home_first else 0x00) # [1] move_home_first + .u16(shake_total_seconds) # [2-3] Shake duration (seconds) + .u8(INTENSITY_TO_BYTE.get(intensity, 0x03)) # [4] Intensity + .u8(0x00) # [5] Reserved + .u16(int(soak_duration)) # [6-7] Soak duration (seconds) + .raw_bytes(b'\x00' * 4) # [8-11] Padding + .finish() + ) # fmt: skip diff --git a/pylabrobot/agilent/biotek/el406/syringe_dispensing_backend.py b/pylabrobot/agilent/biotek/el406/syringe_dispensing_backend.py new file mode 100644 index 00000000000..7ef7c68dbb1 --- /dev/null +++ b/pylabrobot/agilent/biotek/el406/syringe_dispensing_backend.py @@ -0,0 +1,385 @@ +"""EL406 syringe pump step methods. + +Provides syringe_dispense and syringe_prime operations +plus their corresponding command builders. +""" + +from __future__ import annotations + +import logging +from typing import Literal + +from pylabrobot.io.binary import Writer +from pylabrobot.resources import Plate + +from .helpers import ( + plate_to_wire_byte, + plate_well_count, +) +from .protocol import build_framed_message, columns_to_column_mask, encode_column_mask +from dataclasses import dataclass +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.bulk_dispensers.syringe.backend import SyringeDispensingBackend +from .driver import EL406Driver + +logger = logging.getLogger(__name__) + +Syringe = Literal["A", "B", "Both"] + + +def syringe_to_byte(syringe: Syringe) -> int: + syringe_upper = syringe.upper() + if syringe_upper == "A": + return 0 + if syringe_upper == "B": + return 1 + if syringe_upper == "BOTH": + return 2 + raise ValueError(f"Invalid syringe: {syringe}") + + +def validate_syringe(syringe: Syringe) -> None: + if syringe.upper() not in {"A", "B", "BOTH"}: + raise ValueError(f"Invalid syringe '{syringe}'. Must be one of: A, B, BOTH") + + +def validate_syringe_flow_rate(flow_rate: int) -> None: + if not 1 <= flow_rate <= 5: + raise ValueError(f"Syringe flow rate must be 1-5, got {flow_rate}") + + +def validate_syringe_volume(volume: float) -> None: + if not 80 <= volume <= 9999: + raise ValueError(f"Syringe volume must be 80-9999 uL, got {volume}") + + +def validate_pump_delay(delay: int) -> None: + if not 0 <= delay <= 5000: + raise ValueError(f"Pump delay must be 0-5000 ms, got {delay}") + + +def validate_submerge_duration(duration: int) -> None: + if not 0 <= duration <= 1439: + raise ValueError(f"Submerge duration must be 0-1439 minutes, got {duration}") + + +class EL406SyringeDispensingBackend(SyringeDispensingBackend): + """Syringe dispensing backend for the BioTek EL406.""" + + @dataclass + class DispenseParams(BackendParams): + """Parameters for syringe dispense. + + Attributes: + syringe: Syringe selection — "A", "B", or "Both". + flow_rate: Flow rate (1-5). Maximum rate depends on volume and plate type. + For 96-well: rate 1 for 10+ uL, rate 2 for 20+ uL, rate 3 for 50+ uL, + rate 4 for 60+ uL, rate 5 for 80+ uL. + For 384-well: rate 1 for 5+ uL, rate 2 for 10+ uL, rate 3 for 25+ uL, + rate 4 for 30+ uL, rate 5 for 40+ uL. + For 1536-well: all rates for 3+ uL. + offset_x: X offset in mm (default 0). + offset_y: Y offset in mm (default 0). + offset_z: Z offset in mm (default 33.6 for 96-well, 25.4 for 1536-well). + pump_delay: Post-dispense delay in seconds (0-5). Wire resolution: 1 ms. + pre_dispense: Whether to enable pre-dispense mode. + pre_dispense_volume: Pre-dispense volume in uL/tube (only used if pre_dispense=True). + num_pre_dispenses: Number of pre-dispenses (default 2). + """ + + syringe: Syringe = "A" + flow_rate: int = 2 + offset_x: float = 0.0 + offset_y: float = 0.0 + offset_z: float = 33.6 + pump_delay: float = 0.0 + pre_dispense: bool = False + pre_dispense_volume: float = 0.0 + num_pre_dispenses: int = 2 + + def __init__(self, driver: EL406Driver) -> None: + self._driver = driver + + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, self.DispenseParams): + backend_params = self.DispenseParams() + + # Group consecutive columns with the same volume, in ascending order + groups: list[tuple[float, list[int]]] = [] + for col in sorted(volumes.keys()): + vol = volumes[col] + if groups and groups[-1][0] == vol: + groups[-1][1].append(col) + else: + groups.append((vol, [col])) + + for vol, cols in groups: + await self._syringe_dispense(plate, volume=vol, columns=cols, params=backend_params) + + @dataclass + class PrimeParams(BackendParams): + """Parameters for syringe prime. + + Attributes: + syringe: Syringe selection — "A" or "B". + flow_rate: Flow rate (1-5). + refills: Number of prime cycles (1-255). + pump_delay: Delay between cycles in seconds (0-5). Wire resolution: 1 ms. + submerge_tips: Submerge tips in fluid after prime (default True). + submerge_duration: Submerge duration in seconds (0-86340, i.e. up to 23:59). + 0 to disable submerge time. Only encoded when submerge_tips=True. + Wire resolution: 60 s (1 minute). + """ + + syringe: Literal["A", "B"] = "A" + flow_rate: int = 5 + refills: int = 2 + pump_delay: float = 0.0 + submerge_tips: bool = True + submerge_duration: float = 0.0 + + async def prime( + self, + plate: Plate, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + if not isinstance(backend_params, self.PrimeParams): + backend_params = self.PrimeParams() + await self._syringe_prime(plate, volume=volume, params=backend_params) + + async def _syringe_dispense( + self, + plate: Plate, + volume: float, + columns: list[int] | None = None, + params: DispenseParams | None = None, + ) -> None: + """Send a single syringe dispense command to the firmware.""" + if params is None: + params = self.DispenseParams() + + pump_delay_ms = round(params.pump_delay * 1000) + + if volume <= 0: + raise ValueError(f"volume must be positive, got {volume}") + validate_syringe(params.syringe) + validate_syringe_flow_rate(params.flow_rate) + validate_pump_delay(pump_delay_ms) + + column_mask = columns_to_column_mask(columns, plate_wells=plate_well_count(plate)) + + logger.info( + "Syringe dispense: %.1f uL from syringe %s, flow rate %d", + volume, + params.syringe, + params.flow_rate, + ) + + # Convert mm → 0.1mm steps for wire protocol + offset_x_steps = round(params.offset_x * 10) + offset_y_steps = round(params.offset_y * 10) + offset_z_steps = round(params.offset_z * 10) + + data = self._build_syringe_dispense_command( + plate=plate, + volume=volume, + syringe=params.syringe, + flow_rate=params.flow_rate, + offset_x=offset_x_steps, + offset_y=offset_y_steps, + offset_z=offset_z_steps, + pump_delay_ms=pump_delay_ms, + pre_dispense=params.pre_dispense, + pre_dispense_volume=params.pre_dispense_volume, + num_pre_dispenses=params.num_pre_dispenses, + column_mask=column_mask, + ) + framed_command = build_framed_message(command=0xA1, data=data) + async with self._driver.batch(plate): + await self._driver._send_step_command(framed_command) + + async def _syringe_prime( + self, + plate: Plate, + volume: float, + params: PrimeParams | None = None, + ) -> None: + """Send a single syringe prime command to the firmware.""" + if params is None: + params = self.PrimeParams() + + pump_delay_ms = round(params.pump_delay * 1000) + if params.submerge_duration != 0 and params.submerge_duration % 60 != 0: + raise ValueError( + f"Submerge duration must be a multiple of 60 seconds (device resolution is 1 minute), " + f"got {params.submerge_duration}" + ) + submerge_duration_min = round(params.submerge_duration / 60) + + validate_syringe(params.syringe) + validate_syringe_volume(volume) + validate_syringe_flow_rate(params.flow_rate) + validate_pump_delay(pump_delay_ms) + validate_submerge_duration(submerge_duration_min) + if not 1 <= params.refills <= 255: + raise ValueError(f"refills must be 1-255, got {params.refills}") + + logger.info( + "Syringe prime: syringe %s, %.1f uL, flow rate %d, %d refills", + params.syringe, + volume, + params.flow_rate, + params.refills, + ) + + data = self._build_syringe_prime_command( + plate=plate, + volume=volume, + syringe=params.syringe, + flow_rate=params.flow_rate, + refills=params.refills, + pump_delay_ms=pump_delay_ms, + submerge_tips=params.submerge_tips, + submerge_duration_min=submerge_duration_min, + ) + framed_command = build_framed_message(command=0xA2, data=data) + prime_timeout = self._driver.timeout + params.submerge_duration + 30 + async with self._driver.batch(plate): + await self._driver._send_step_command(framed_command, timeout=prime_timeout) + + # ========================================================================= + # COMMAND BUILDERS + # ========================================================================= + + def _build_syringe_dispense_command( + self, + plate: Plate, + volume: float, + syringe: Syringe, + flow_rate: int, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 336, + pump_delay_ms: int = 0, + pre_dispense: bool = False, + pre_dispense_volume: float = 0.0, + num_pre_dispenses: int = 2, + column_mask: list[int] | None = None, + ) -> bytes: + """Build syringe dispense command bytes. + + Wire format (26 bytes): + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1] Syringe: A=0, B=1, Both=2 + [2-3] Volume: 2 bytes, little-endian, in uL + [4] Flow rate: 1-5 + [5] Offset X: signed byte + [6] Offset Y: signed byte + [7-8] Offset Z: 2 bytes, little-endian + [9-10] Pump delay: 2 bytes, little-endian, in ms + [11-12] Pre-dispense volume: 2 bytes, little-endian (0 if pre_dispense=False) + [13] Number of pre-dispenses (default 2) + [14-19] Column mask: 6 bytes (48 bits packed) + [20] Bottle selection (A→0, B→2, Both→4) + [21-25] Padding (5 bytes) + + Args: + volume: Dispense volume in microliters. + syringe: Syringe selection (A, B, Both). + flow_rate: Flow rate (1-5). + offset_x: X offset (signed, 0.1mm units). + offset_y: Y offset (signed, 0.1mm units). + offset_z: Z offset (0.1mm units). + pump_delay_ms: Post-dispense delay in milliseconds. + pre_dispense: Whether to enable pre-dispense mode. + pre_dispense_volume: Pre-dispense volume in uL/tube (only used if pre_dispense=True). + num_pre_dispenses: Number of pre-dispenses (default 2). + column_mask: List of column indices (0-47) or None for all columns. + + Returns: + Command bytes (26 bytes). + """ + pre_disp_vol_int = int(pre_dispense_volume) if pre_dispense else 0 + bottle_byte = {"A": 0, "B": 2, "BOTH": 4}.get(syringe.upper(), 0) + + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(syringe_to_byte(syringe)) # [1] Syringe + .u16(int(volume)) # [2-3] Volume (LE) + .u8(flow_rate) # [4] Flow rate + .i8(offset_x) # [5] Offset X + .i8(offset_y) # [6] Offset Y + .u16(offset_z) # [7-8] Offset Z (LE) + .u16(pump_delay_ms) # [9-10] Pump delay (LE) + .u16(pre_disp_vol_int) # [11-12] Pre-dispense vol (LE) + .u8(num_pre_dispenses) # [13] Num pre-dispenses + .raw_bytes(encode_column_mask(column_mask)) # [14-19] Column mask + .u8(bottle_byte) # [20] Bottle selection + .raw_bytes(b'\x00' * 5) # [21-25] Padding + .finish() + ) # fmt: skip + + def _build_syringe_prime_command( + self, + plate: Plate, + volume: float, + syringe: Literal["A", "B"], + flow_rate: int, + refills: int = 2, + pump_delay_ms: int = 0, + submerge_tips: bool = True, + submerge_duration_min: int = 0, + ) -> bytes: + """Build syringe prime command bytes. + + Protocol format (13 bytes): + [0] Plate type (wire byte, e.g. 0x04=96-well) + [1] Syringe: A=0, B=1 + [2-3] Volume: 2 bytes, little-endian, in uL + [4] Flow rate: 1-5 + [5] Refills: byte (number of prime cycles) + [6-7] Pump delay: 2 bytes, little-endian, in ms + [8] Submerge tips (0 or 1) — "Submerge tips in fluid after prime" + [9-10] Submerge duration in minutes (LE uint16). 0 if submerge_tips=False. + [11] Bottle: derived from syringe (A->0, B->2) + [12] Padding + + Args: + volume: Prime volume in microliters. + syringe: Syringe selection (A, B). + flow_rate: Flow rate (1-5). + refills: Number of prime cycles. + pump_delay_ms: Delay between cycles in milliseconds (default 0). + submerge_tips: Submerge tips in fluid after prime (default True). + submerge_duration_min: Submerge duration in minutes (0-1439). Only encoded + when submerge_tips=True. + + Returns: + Command bytes (13 bytes). + """ + sub_total = submerge_duration_min if (submerge_tips and submerge_duration_min > 0) else 0 + bottle_byte = {"A": 0, "B": 2}.get(syringe.upper(), 0) + + return ( + Writer() + .u8(plate_to_wire_byte(plate)) # [0] Plate type + .u8(syringe_to_byte(syringe)) # [1] Syringe (A=0, B=1) + .u16(int(volume)) # [2-3] Volume (LE) + .u8(flow_rate) # [4] Flow rate + .u8(refills & 0xFF) # [5] Refills + .u16(pump_delay_ms) # [6-7] Pump delay (LE) + .u8(1 if submerge_tips else 0) # [8] Submerge tips + .u16(sub_total) # [9-10] Submerge duration (LE, minutes) + .u8(bottle_byte) # [11] Bottle selection + .u8(0x00) # [12] Padding + .finish() + ) # fmt: skip diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/actions.py b/pylabrobot/legacy/plate_washing/biotek/el406/actions.py index 870fa27bca5..6e7999d50a4 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/actions.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/actions.py @@ -1,162 +1,6 @@ -"""EL406 action and control methods. +"""EL406 action and control methods — legacy re-export. -This module contains the mixin class for action/control operations on the -BioTek EL406 plate washer (reset, home, pause, resume, etc.). +Implementation has moved to pylabrobot.agilent.biotek.el406.driver. """ -from __future__ import annotations - -import logging - -from .communication import LONG_READ_TIMEOUT -from .enums import ( - EL406Motor, - EL406MotorHomeType, - EL406StepType, - EL406WasherManifold, -) -from .protocol import build_framed_message - -logger = logging.getLogger(__name__) - - -class EL406ActionsMixin: - """Mixin providing action/control methods for the EL406. - - This mixin provides: - - Abort, pause, resume operations - - Reset instrument - - Home/verify motors - - End-of-batch operations - - Auto-prime operations - - Set washer manifold - - Requires: - self._send_framed_command: Async method for sending framed commands - self._send_action_command: Async method for sending action commands - """ - - async def _send_framed_command( - self, - framed_message: bytes, - timeout: float | None = None, - ) -> bytes: - raise NotImplementedError - - async def _send_action_command( - self, - framed_message: bytes, - timeout: float | None = None, - ) -> bytes: - raise NotImplementedError - - async def abort( - self, - step_type: EL406StepType | None = None, - ) -> None: - """Abort a running operation. - - Args: - step_type: Optional step type to abort. If None, aborts current operation. - - Raises: - RuntimeError: If device not initialized. - TimeoutError: If timeout waiting for ACK response. - """ - logger.info( - "Aborting %s", - f"step type {step_type.name}" if step_type is not None else "current operation", - ) - - step_type_value = step_type.value if step_type is not None else 0 - data = bytes([step_type_value]) - framed_command = build_framed_message(command=0x89, data=data) - await self._send_framed_command(framed_command) - - async def pause(self) -> None: - """Pause a running operation.""" - logger.info("Pausing operation") - framed_command = build_framed_message(command=0x8A) - await self._send_framed_command(framed_command) - - async def resume(self) -> None: - """Resume a paused operation.""" - logger.info("Resuming operation") - framed_command = build_framed_message(command=0x8B) - await self._send_framed_command(framed_command) - - async def reset(self) -> None: - """Reset the instrument to a known state.""" - logger.info("Resetting instrument") - framed_command = build_framed_message(command=0x70) - await self._send_action_command(framed_command, timeout=LONG_READ_TIMEOUT) - logger.info("Instrument reset complete") - - async def _perform_end_of_batch(self) -> None: - """Perform end-of-batch activities - sends completion marker. - - NOTE: This command (140) is just a completion marker and does NOT: - - Stop the pump - - Home the syringes - - For a complete cleanup after a protocol, use cleanup_after_protocol() instead. - """ - logger.info("Performing end-of-batch activities (completion marker)") - framed_command = build_framed_message(command=0x8C) - await self._send_action_command(framed_command, timeout=60.0) - logger.info("End-of-batch marker sent") - - async def cleanup_after_protocol(self) -> None: - """Complete cleanup after running a protocol. - - This method performs the full cleanup sequence that the original BioTek - software does after all protocol steps complete: - 1. Home the syringes (XYZ motors) - 2. Send end-of-batch completion marker - - This is the recommended way to end a protocol run. - - Example: - >>> # Run protocol steps - >>> await backend.syringe_prime("A", 1000, 5, 2) - >>> await backend.syringe_prime("B", 1000, 5, 2) - >>> # Then cleanup - >>> await backend.cleanup_after_protocol() - """ - logger.info("Starting post-protocol cleanup") - - # Step 1: Home syringes - logger.info(" Homing motors...") - await self.home_motors(EL406MotorHomeType.HOME_XYZ_MOTORS) - - # Step 2: Send end-of-batch marker - logger.info(" Sending end-of-batch marker...") - await self._perform_end_of_batch() - - logger.info("Post-protocol cleanup complete") - - async def home_motors( - self, - home_type: EL406MotorHomeType, - motor: EL406Motor | None = None, - ) -> None: - """Home or verify motor positions.""" - logger.info( - "Home/verify motors: type=%s, motor=%s", - home_type.name, - motor.name if motor is not None else "default(0)", - ) - - motor_num = motor.value if motor is not None else 0 - data = bytes([home_type.value, motor_num]) - framed_command = build_framed_message(command=0xC8, data=data) - await self._send_action_command(framed_command, timeout=120.0) - logger.info("Motors homed") - - async def set_washer_manifold(self, manifold: EL406WasherManifold) -> None: - """Set the washer manifold type.""" - logger.info("Setting washer manifold to: %s", manifold.name) - data = bytes([manifold.value]) - framed_command = build_framed_message(command=0xD9, data=data) - await self._send_framed_command(framed_command) - logger.info("Washer manifold set to: %s", manifold.name) +from pylabrobot.agilent.biotek.el406.driver import EL406Driver as EL406ActionsMixin # noqa: F401 diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/backend.py b/pylabrobot/legacy/plate_washing/biotek/el406/backend.py index 8e45acd3414..de08dd45ca1 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/backend.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/backend.py @@ -17,26 +17,32 @@ import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from typing import Literal from pylabrobot.io.ftdi import FTDI from pylabrobot.legacy.machines.backend import MachineBackend from pylabrobot.resources import Plate -from .actions import EL406ActionsMixin -from .communication import EL406CommunicationMixin +from pylabrobot.agilent.biotek.el406.driver import EL406Driver +from pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend import ( + Cassette, + EL406PeristalticDispensingBackend, + PeristalticFlowRate, +) +from pylabrobot.agilent.biotek.el406.plate_washing_backend import EL406PlateWashingBackend +from pylabrobot.agilent.biotek.el406.shaking_backend import EL406ShakingBackend +from pylabrobot.agilent.biotek.el406.syringe_dispensing_backend import ( + EL406SyringeDispensingBackend, + Syringe, +) + from .errors import EL406CommunicationError from .helpers import plate_to_wire_byte -from .queries import EL406QueriesMixin -from .steps import EL406StepsMixin logger = logging.getLogger(__name__) class ExperimentalBioTekEL406Backend( - EL406CommunicationMixin, - EL406QueriesMixin, - EL406ActionsMixin, - EL406StepsMixin, MachineBackend, ): """Backend for BioTek EL406 plate washer. @@ -65,130 +71,289 @@ def __init__( device_id: FTDI device serial number for explicit connection. """ super().__init__() - self.timeout = timeout self._device_id = device_id - self.io: FTDI | None = None self._command_lock: asyncio.Lock | None = None self._in_batch: bool = False + # New architecture: driver + capability backends + self._new_driver = EL406Driver(timeout=timeout, device_id=device_id) + + self._plate_washing = EL406PlateWashingBackend(self._new_driver) + self._shaking = EL406ShakingBackend(self._new_driver) + self._syringe = EL406SyringeDispensingBackend(self._new_driver) + self._peristaltic = EL406PeristalticDispensingBackend(self._new_driver) + + @property + def io(self): + return self._new_driver.io + + @io.setter + def io(self, value): + self._new_driver.io = value + + @property + def timeout(self) -> float: + return self._new_driver.timeout + + @timeout.setter + def timeout(self, value: float) -> None: + self._new_driver.timeout = value + async def setup( self, skip_reset: bool = False, ) -> None: """Set up communication with the EL406. - Configures the FTDI USB interface with the correct parameters: - - 38400 baud - - 8 data bits, 2 stop bits, no parity (8N2) - - No flow control (disabled) - - If ``self.io`` is already set (e.g. injected mock for testing), - it is used as-is and ``setup()`` is not called on it again. - - Note: This does NOT start a batch. Use ``batch()`` or call step commands - directly (they auto-batch). - Args: skip_reset: If True, skip the instrument reset step. - - Raises: - RuntimeError: If pylibftdi is not installed or communication fails. """ - self._command_lock = asyncio.Lock() - - logger.info("BioTekEL406Backend setting up") - logger.info(" Timeout: %.1f seconds", self.timeout) - - if self.io is None: - self.io = FTDI(human_readable_device_name="BioTek EL406", device_id=self._device_id) - await self.io.setup() - - # Configure serial parameters - logger.debug("Configuring serial parameters...") - try: - await self.io.set_baudrate(38400) - await self.io.set_line_property(8, 2, 0) # 8 data bits, 2 stop bits, no parity - logger.info(" Serial: 38400 baud, 8N2") - - SIO_DISABLE_FLOW_CTRL = 0x0 - await self.io.set_flowctrl(SIO_DISABLE_FLOW_CTRL) - logger.info(" Flow control: NONE") - - await self.io.set_rts(True) - await self.io.set_dtr(True) - logger.debug(" RTS and DTR enabled") - except Exception as e: - await self.io.stop() - self.io = None - raise EL406CommunicationError( - f"Failed to configure FTDI device: {e}", - operation="configure", - original_error=e, - ) from e - - # Purge buffers - logger.debug("Purging TX/RX buffers...") - await self._purge_buffers() - - # Test communication - logger.info("Testing communication with device...") - try: - await self._test_communication() - logger.info(" Communication test: PASSED") - except Exception as e: - logger.error(" Communication test: FAILED - %s", e) - raise - - if not skip_reset: - logger.info("Performing full instrument reset...") - await self.reset() - logger.info(" Instrument reset: DONE") + # If io was injected (e.g. mock for testing), pass it to the driver + if self.io is not None: + self._new_driver.io = self.io + await self._new_driver.setup(skip_reset=skip_reset) + # Sync back so legacy code can access io/lock + self.io = self._new_driver.io + self._command_lock = self._new_driver._command_lock logger.info("BioTekEL406Backend setup complete") async def stop(self) -> None: - """Stop communication with the EL406. - - Closes the FTDI connection. Batch cleanup is handled by the ``batch()`` - context manager, not by ``stop()``. - """ - logger.info("BioTekEL406Backend stopping") - if self.io is not None: - await self.io.stop() - self.io = None + """Stop communication with the EL406.""" + await self._new_driver.stop() + self.io = None @asynccontextmanager async def batch(self, plate: Plate) -> AsyncIterator[None]: - """Context manager for batching step commands. - - Each step command (manifold_wash, syringe_prime, etc.) automatically wraps - its execution in a batch. Use this context manager to group multiple step - commands into a single batch, avoiding repeated start/cleanup cycles. - - If already inside a batch, this is a no-op passthrough. - - Args: - plate: PLR Plate to configure for this batch. - - Example: - >>> async with backend.batch(plate_96): - ... await backend.manifold_prime(plate_96, volume=5000) - ... await backend.manifold_wash(plate_96, cycles=3) - ... await backend.syringe_dispense(plate_96, volume=50) - """ + """Context manager for batching step commands.""" if self._in_batch: yield return self._in_batch = True + self._new_driver._in_batch = True try: - await self.start_batch(plate_to_wire_byte(plate)) + await self._new_driver.start_batch(plate_to_wire_byte(plate)) yield finally: try: - await self.cleanup_after_protocol() + await self._new_driver.cleanup_after_protocol() finally: self._in_batch = False + self._new_driver._in_batch = False + + # Query mixin needs these — delegate to driver + async def _send_framed_query(self, command, data=b"", timeout=None): + return await self._new_driver._send_framed_query(command, data=data, timeout=timeout) + + async def _send_framed_command(self, framed_message, timeout=None): + return await self._new_driver._send_framed_command(framed_message, timeout=timeout) + + async def _test_communication(self): + return await self._new_driver._test_communication() + + # --------------------------------------------------------------------------- + # Queries — delegate to driver + # --------------------------------------------------------------------------- + + async def request_washer_manifold(self): + return await self._new_driver.request_washer_manifold() + + async def request_syringe_manifold(self): + return await self._new_driver.request_syringe_manifold() + + async def request_serial_number(self): + return await self._new_driver.request_serial_number() + + async def request_sensor_enabled(self, sensor): + return await self._new_driver.request_sensor_enabled(sensor) + + async def request_syringe_box_info(self): + return await self._new_driver.request_syringe_box_info() + + async def request_peristaltic_installed(self, selector): + return await self._new_driver.request_peristaltic_installed(selector) + + async def request_instrument_settings(self): + return await self._new_driver.request_instrument_settings() + + async def run_self_check(self): + return await self._new_driver.run_self_check() + + # --------------------------------------------------------------------------- + # Device-level operations — delegate to driver + # --------------------------------------------------------------------------- + + async def abort(self, step_type=None): + await self._new_driver.abort(step_type=step_type) + + async def pause(self): + await self._new_driver.pause() + + async def resume(self): + await self._new_driver.resume() + + async def reset(self): + await self._new_driver.reset() + + async def home_motors(self, home_type, motor=None): + await self._new_driver.home_motors(home_type, motor=motor) + + async def set_washer_manifold(self, manifold): + await self._new_driver.set_washer_manifold(manifold) + + # --------------------------------------------------------------------------- + # Manifold methods — delegate to new EL406PlateWashingBackend + # --------------------------------------------------------------------------- + + async def manifold_aspirate(self, plate, **kwargs): + async with self.batch(plate): + await self._plate_washing.manifold_aspirate(plate, **kwargs) + + async def manifold_dispense(self, plate, volume, **kwargs): + async with self.batch(plate): + await self._plate_washing.manifold_dispense(plate, volume=volume, **kwargs) + + async def manifold_wash(self, plate, **kwargs): + async with self.batch(plate): + await self._plate_washing.manifold_wash(plate, **kwargs) + + async def manifold_prime(self, plate, volume, **kwargs): + async with self.batch(plate): + await self._plate_washing.manifold_prime(plate, volume=volume, **kwargs) + + async def manifold_auto_clean(self, plate, **kwargs): + async with self.batch(plate): + await self._plate_washing.manifold_auto_clean(plate, **kwargs) + + # --------------------------------------------------------------------------- + # Shake — delegate to new EL406ShakingBackend + # --------------------------------------------------------------------------- + + async def shake(self, plate, **kwargs): + async with self.batch(plate): + await self._shaking.shake(plate, **kwargs) + + # --------------------------------------------------------------------------- + # Syringe — delegate to new EL406SyringeDispensingBackend + # --------------------------------------------------------------------------- + + async def syringe_dispense( + self, + plate: Plate, + volume: float, + syringe: Syringe = "A", + flow_rate: int = 2, + offset_x: int = 0, + offset_y: int = 0, + offset_z: int = 336, + pump_delay: float = 0.0, + pre_dispense: bool = False, + pre_dispense_volume: float = 0.0, + num_pre_dispenses: int = 2, + columns: list[int] | None = None, + ) -> None: + async with self.batch(plate): + params = EL406SyringeDispensingBackend.DispenseParams( + syringe=syringe, + flow_rate=flow_rate, + offset_x=offset_x / 10, # legacy 0.1mm → mm + offset_y=offset_y / 10, + offset_z=offset_z / 10, + pump_delay=pump_delay, + pre_dispense=pre_dispense, + pre_dispense_volume=pre_dispense_volume, + num_pre_dispenses=num_pre_dispenses, + ) + await self._syringe._syringe_dispense(plate, volume=volume, columns=columns, params=params) + + async def syringe_prime( + self, + plate: Plate, + syringe: Literal["A", "B"] = "A", + volume: float = 5000.0, + flow_rate: int = 5, + refills: int = 2, + pump_delay: float = 0.0, + submerge_tips: bool = True, + submerge_duration: float = 0.0, + ) -> None: + async with self.batch(plate): + params = EL406SyringeDispensingBackend.PrimeParams( + syringe=syringe, + flow_rate=flow_rate, + refills=refills, + pump_delay=pump_delay, + submerge_tips=submerge_tips, + submerge_duration=submerge_duration, + ) + await self._syringe._syringe_prime(plate, volume=volume, params=params) + + # --------------------------------------------------------------------------- + # Peristaltic — delegate to new EL406PeristalticDispensingBackend + # --------------------------------------------------------------------------- + + async def peristaltic_prime( + self, + plate: Plate, + volume: float | None = None, + duration: int | None = None, + flow_rate: PeristalticFlowRate = "High", + cassette: Cassette = "Any", + ) -> None: + async with self.batch(plate): + params = EL406PeristalticDispensingBackend.PrimeParams( + flow_rate=flow_rate, + cassette=cassette, + ) + await self._peristaltic._peristaltic_prime( + plate, volume=volume, duration=duration, params=params, + ) + + async def peristaltic_dispense( + self, + plate: Plate, + volume: float, + flow_rate: PeristalticFlowRate = "High", + offset_x: int = 0, + offset_y: int = 0, + offset_z: int | None = None, + pre_dispense_volume: float = 10.0, + num_pre_dispenses: int = 2, + cassette: Cassette = "Any", + columns: list[int] | None = None, + rows: list[int] | None = None, + ) -> None: + async with self.batch(plate): + params = EL406PeristalticDispensingBackend.DispenseParams( + flow_rate=flow_rate, + offset_x=offset_x / 10 if offset_x is not None else 0.0, # legacy 0.1mm → mm + offset_y=offset_y / 10 if offset_y is not None else 0.0, + offset_z=offset_z / 10 if offset_z is not None else None, + pre_dispense_volume=pre_dispense_volume, + num_pre_dispenses=num_pre_dispenses, + cassette=cassette, + columns=columns, + rows=rows, + ) + await self._peristaltic._peristaltic_dispense(plate, volume=volume, params=params) + + async def peristaltic_purge( + self, + plate: Plate, + volume: float | None = None, + duration: int | None = None, + flow_rate: PeristalticFlowRate = "High", + cassette: Cassette = "Any", + ) -> None: + async with self.batch(plate): + params = EL406PeristalticDispensingBackend.PrimeParams( + flow_rate=flow_rate, + cassette=cassette, + ) + await self._peristaltic._peristaltic_purge( + plate, volume=volume, duration=duration, params=params, + ) def serialize(self) -> dict: """Serialize backend configuration.""" diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/communication.py b/pylabrobot/legacy/plate_washing/biotek/el406/communication.py index df2be8f6ad4..989abfa34ad 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/communication.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/communication.py @@ -1,558 +1,10 @@ -"""EL406 low-level communication methods. +"""EL406 low-level communication methods — legacy re-export. -This module contains the mixin class for low-level USB/FTDI communication -with the BioTek EL406 plate washer. +Implementation has moved to pylabrobot.agilent.biotek.el406.driver. """ -from __future__ import annotations - -import asyncio -import logging -import time -from typing import TYPE_CHECKING, NamedTuple - -from pylabrobot.io.binary import Reader - -from .error_codes import get_error_message -from .errors import EL406CommunicationError, EL406DeviceError -from .protocol import build_framed_message - -LONG_READ_TIMEOUT = 120.0 # seconds, for long operations (wash cycles can take >30s) - -STATE_INITIAL = 1 -STATE_RUNNING = 2 -STATE_PAUSED = 3 -STATE_STOPPED = 4 - -if TYPE_CHECKING: - from pylabrobot.io.ftdi import FTDI - - -class DevicePollResult(NamedTuple): - """Parsed result from a STATUS_POLL response.""" - - validity: int - state: int - status: int - raw_response: bytes - - -logger = logging.getLogger(__name__) - - -class EL406CommunicationMixin: - """Mixin providing low-level communication methods for the EL406. - - This mixin provides: - - Buffer purging - - Framed command sending - - Action command sending (with completion wait) - - Framed query sending - - Low-level byte reading - - Requires: - self.io: FTDI IO wrapper instance - self.timeout: Default timeout in seconds - self._command_lock: asyncio.Lock for command serialization - """ - - io: FTDI | None - timeout: float - _command_lock: asyncio.Lock | None - - async def _write_to_device(self, data: bytes) -> None: - """Write bytes to the FTDI device, wrapping errors. - - Raises: - EL406CommunicationError: If the write fails. - """ - assert self.io is not None - try: - await self.io.write(data) - except Exception as e: - raise EL406CommunicationError( - f"Failed to write to device: {e}. Device may have disconnected.", - operation="write", - original_error=e, - ) from e - - async def _wait_for_ack(self, timeout: float, t0: float) -> None: - """Poll device for ACK byte within the remaining timeout window. - - Args: - timeout: Total timeout budget in seconds. - t0: Start timestamp (from ``time.monotonic()``). - - Raises: - RuntimeError: If device sends NAK. - TimeoutError: If no ACK within timeout. - """ - assert self.io is not None - while time.monotonic() - t0 < timeout: - byte = await self.io.read(1) - if byte: - if byte[0] == 0x15: # NAK - raise RuntimeError( - f"Device rejected command (NAK). Response: {byte!r}. " - "This may indicate an invalid command, bad parameters, or device busy state." - ) - if byte[0] == 0x06: # ACK - return - await asyncio.sleep(0.01) - raise TimeoutError("Timeout waiting for ACK") - - async def _read_exact_bytes(self, count: int, timeout: float, t0: float) -> bytes: - """Read exactly *count* bytes from the device, polling until done or timeout. - - Args: - count: Number of bytes to read. - timeout: Total timeout budget in seconds. - t0: Start timestamp (from ``time.monotonic()``). - - Returns: - Bytes read (may be shorter than *count* if timeout is reached). - """ - assert self.io is not None - buf = b"" - while len(buf) < count and time.monotonic() - t0 < timeout: - chunk = await self.io.read(count - len(buf)) - if chunk: - buf += chunk - else: - await asyncio.sleep(0.01) - return buf - - async def _purge_buffers(self) -> None: - """Purge the RX and TX buffers.""" - if self.io is None: - return - - try: - for _ in range(6): - await self.io.usb_purge_rx_buffer() - await self.io.usb_purge_tx_buffer() - except Exception as e: - raise EL406CommunicationError( - f"Failed to purge FTDI buffers: {e}. Device may have disconnected.", - operation="purge", - original_error=e, - ) from e - - async def _test_communication(self) -> None: - """Test communication with the device. - - Sends framed command 0x73 (115) and expects ACK (0x06) response. - - Raises: - RuntimeError: If communication test fails. - """ - if self.io is None: - raise RuntimeError("EL406 communication test failed: device not open") - - try: - framed_command = build_framed_message(command=0x73) - response = await self._send_framed_command(framed_command, timeout=self.timeout) - if 0x06 not in response: - raise RuntimeError( - f"EL406 communication test failed: expected ACK (0x06), got {response!r}" - ) - except TimeoutError as e: - raise RuntimeError(f"EL406 communication test failed: timeout - {e}") from e - - logger.info("EL406 communication test passed") - - # Send INIT_STATE (0xA0) command to clear device state - logger.info("Sending INIT_STATE command (0xA0) to clear device state") - init_state_cmd = build_framed_message(command=0xA0) - init_response = await self._send_framed_command(init_state_cmd, timeout=self.timeout) - logger.debug("INIT_STATE sent, response: %s", init_response.hex()) - - async def start_batch(self, wire_byte: int) -> None: - """Send START_STEP command to begin a batch of step operations. - - Use this function at the beginning of a protocol, before executing any step - commands. This puts the device in "ready to execute steps" mode. Must be - called once before running step commands like prime, dispense, aspirate, - shake, etc. - - This should be called: - - After setup() completes - - Before running any step commands - - Only once per batch of operations (not before each individual step) - - Args: - wire_byte: EL406 plate-type byte for the wire protocol. - """ - if self.io is None: - raise RuntimeError("Device not initialized - call setup() first") - - logger.info("Sending START_STEP to begin batch operations") - - # Send initialization commands before START_STEP - pre_batch_commands = [0xBF, 0xC1, 0xF2, 0xF4, 0x0154, 0x0102, 0x010A] - for cmd in pre_batch_commands: - cmd_frame = build_framed_message(cmd) - try: - resp = await self._send_framed_command(cmd_frame, timeout=self.timeout) - logger.debug("Command 0x%04X response: %s", cmd, resp.hex()) - except Exception as e: - logger.warning("Pre-batch command 0x%04X failed: %s", cmd, e) - - # Data byte is the plate type value (e.g., 0x04 for 96-well, 0x01 for 384-well). - start_step_data = bytes([wire_byte]) - start_step_cmd = build_framed_message(command=0x8D, data=start_step_data) - response = await self._send_framed_command(start_step_cmd, timeout=self.timeout) - logger.debug("START_STEP sent, response: %s", response.hex()) - - async def _send_framed_command( - self, - framed_message: bytes, - timeout: float | None = None, - ) -> bytes: - """Send a framed command and wait for full response. - - The device responds to framed commands with: - - ACK (0x06) + 11-byte header + N-byte data - - This method reads the complete response to avoid leaving data in the buffer. - For ACK-only commands (e.g. TEST_COMM, INIT_STATE), the header wait acts as - an implicit settling delay that the device needs before accepting further - commands. - - Args: - framed_message: Complete framed message (from build_framed_message). - timeout: Timeout in seconds. - - Returns: - Complete response bytes (ACK + header + data). - - Raises: - TimeoutError: If timeout waiting for response. - """ - if self.io is None or self._command_lock is None: - raise RuntimeError("Device not initialized") - - if timeout is None: - timeout = self.timeout - - async with self._command_lock: - await self._purge_buffers() - - # Send header and data separately - header = framed_message[:11] - data = framed_message[11:] if len(framed_message) > 11 else b"" - - await self._write_to_device(header) - logger.debug("Sent header: %s", header.hex()) - - if data: - await asyncio.sleep(0.001) # Small delay between header and data - await self._write_to_device(data) - logger.debug("Sent data: %s", data.hex()) - logger.debug("Sent framed: %s", framed_message.hex()) - - # Read full response: ACK + 11-byte header + variable data - await self._wait_for_ack(timeout, time.monotonic()) - result = bytes([0x06]) - - # Fresh timestamp after ACK — header + data share a single timeout budget. - t0 = time.monotonic() - resp_header = await self._read_exact_bytes(11, timeout, t0) - - if len(resp_header) == 11: - result += resp_header - # Parse data length from header bytes 7-8 (little-endian) - data_len = Reader(resp_header[7:]).u16() - response_data = await self._read_exact_bytes(data_len, timeout, t0) - result += response_data - logger.debug("Full response: %s (%d bytes)", result.hex(), len(result)) - else: - logger.debug("ACK-only response (no frame): %s", result.hex()) - - return result - - async def _send_action_command( - self, - framed_message: bytes, - timeout: float | None = None, - ) -> bytes: - """Send an action command and wait for completion frame. - - Action commands (like reset, home_motors) work differently from query commands: - 1. Send command - 2. Device sends ACK immediately (acknowledging receipt) - 3. Device performs the physical action (takes time) - 4. Device sends completion frame when done - - This method waits for both the ACK and the completion frame. - - Args: - framed_message: Complete framed message (from build_framed_message). - timeout: Timeout in seconds for the entire operation including action completion. - - Returns: - Completion frame bytes (header + data). - - Raises: - TimeoutError: If timeout waiting for ACK or completion. - RuntimeError: If device rejects command (NAK). - """ - if self.io is None or self._command_lock is None: - raise RuntimeError("Device not initialized") - - if timeout is None: - timeout = LONG_READ_TIMEOUT # Default to long timeout for actions - - async with self._command_lock: - await self._purge_buffers() - - # Send header and data separately (matches _send_framed_command protocol) - header = framed_message[:11] - data = framed_message[11:] if len(framed_message) > 11 else b"" - - await self._write_to_device(header) - if data: - await asyncio.sleep(0.001) - await self._write_to_device(data) - logger.debug("Sent action command: %s", framed_message.hex()) - - t0 = time.monotonic() - - # Step 1: Wait for ACK (short timeout) - await self._wait_for_ack(min(timeout, self.timeout), t0) - logger.debug("Got ACK, waiting for completion...") - - # Step 2: Wait for completion frame (11-byte header + data) - header = await self._read_exact_bytes(11, timeout, t0) - if len(header) < 11: - raise TimeoutError(f"Timeout waiting for completion header (got {len(header)} bytes)") - - # Parse data length and read remaining data - data_len = Reader(header[7:]).u16() - data = await self._read_exact_bytes(data_len, timeout, t0) - - result = header + data - - logger.debug("Completion frame: %s (%d bytes)", result.hex(), len(result)) - - # Parse and log result - cmd_echo = Reader(result[2:]).u16() - response_data = result[11 : 11 + data_len] if len(result) >= 11 + data_len else b"" - logger.debug(" Command echo: 0x%04X, data: %s", cmd_echo, response_data.hex()) - - return result - - async def _send_framed_query( - self, - command: int, - data: bytes = b"", - timeout: float | None = None, - ) -> bytes: - """Send a framed query command and read full response with header and data. - - Sends the 11-byte header and optional data payload as separate USB writes, - then reads the full response: ACK + 11-byte response header + data. - - Args: - command: 16-bit command code - data: Optional data bytes to send with command - timeout: Timeout in seconds - - Returns: - Data bytes from response (header stripped). - - Raises: - RuntimeError: If device not initialized or response invalid. - TimeoutError: If timeout waiting for response. - """ - if self.io is None or self._command_lock is None: - raise RuntimeError("Device not initialized") - - if timeout is None: - timeout = self.timeout - - framed_message = build_framed_message(command, data) - - async with self._command_lock: - await self._purge_buffers() - - # Split header and data - msg_header = framed_message[:11] - msg_data = framed_message[11:] if len(framed_message) > 11 else b"" - - await self._write_to_device(msg_header) - logger.debug("Sent query header 0x%04X: %s", command, msg_header.hex()) - - if msg_data: - await asyncio.sleep(0.001) - await self._write_to_device(msg_data) - logger.debug("Sent query data: %s", msg_data.hex()) - - # Wait for ACK - try: - await self._wait_for_ack(timeout, time.monotonic()) - except RuntimeError as e: - raise RuntimeError( - f"Device rejected command 0x{command:04X} (NAK). Check command code and parameters." - ) from e - except TimeoutError as e: - raise TimeoutError(f"Timeout waiting for ACK (command 0x{command:04X})") from e - - t0 = time.monotonic() - # Read 11-byte response header (shares timeout budget with data) - resp_header = await self._read_exact_bytes(11, timeout, t0) - if len(resp_header) < 11: - raise TimeoutError(f"Timeout reading response header (got {len(resp_header)}/11 bytes)") - - logger.debug("Response header: %s", resp_header.hex()) - - # Parse data length from header bytes 7-8 (little-endian) - data_len = Reader(resp_header[7:]).u16() - logger.debug("Response data length: %d", data_len) - - # Read data bytes - response_data = await self._read_exact_bytes(data_len, timeout, t0) - if len(response_data) < data_len: - raise TimeoutError( - f"Timeout reading response data (got {len(response_data)}/{data_len} bytes)" - ) - - logger.debug("Response data: %s", response_data.hex()) - return response_data - - async def _poll_device_state(self) -> DevicePollResult: - """Send one STATUS_POLL and return the parsed device state. - - Returns: - DevicePollResult with validity, state, status, and raw_response. - - Raises: - EL406CommunicationError: If poll response is too short to parse. - """ - poll_command = build_framed_message(command=0x92) - poll_response = await self._send_framed_command(poll_command, timeout=self.timeout) - logger.debug("Status poll response (%d bytes): %s", len(poll_response), poll_response.hex()) - - if len(poll_response) < 21: - # Short response — return zeroed fields so callers can handle it - return DevicePollResult(validity=0, state=0, status=0, raw_response=poll_response) - - # Data layout (after ACK+header at offset 12): - # bytes 12-13: validity (little-endian, must be 0) - # bytes 14-15: state (little-endian) - # bytes 16-19: timestamp/counter - # byte 20: status code - r = Reader(poll_response[12:]) - validity = r.u16() - state = r.u16() - r.raw_bytes(4) # skip timestamp/counter (bytes 16-19) - status = r.u8() - - if validity != 0: - error_msg = get_error_message(validity) - logger.warning("Status poll returned error 0x%04X (%d): %s", validity, validity, error_msg) - - logger.debug("Status poll: validity=%d, state=%d, status=%d", validity, state, status) - return DevicePollResult( - validity=validity, state=state, status=status, raw_response=poll_response - ) - - async def _wait_until_ready(self, timeout: float = 5.0, poll_interval: float = 0.1) -> None: - """Poll until the device is no longer in STATE_RUNNING. - - Args: - timeout: Maximum time to wait in seconds. - poll_interval: Time between polls in seconds. - - Raises: - TimeoutError: If the device stays busy beyond *timeout*. - """ - t0 = time.monotonic() - while time.monotonic() - t0 < timeout: - poll = await self._poll_device_state() - if poll.state != STATE_RUNNING: - return - await asyncio.sleep(poll_interval) - raise TimeoutError(f"Device still busy (STATE_RUNNING) after {timeout}s waiting for readiness") - - async def _send_step_command( - self, - framed_message: bytes, - timeout: float | None = None, - poll_interval: float = 0.1, - ) -> bytes: - """Send a step command and poll for completion. - - Step commands (prime, dispense, aspirate, shake, etc.) require polling - for completion using STATUS_POLL (0x92) until the operation completes. - - Protocol flow: - 1. Wait for device to be ready (not RUNNING) - 2. Send step command (e.g., SYRINGE_PRIME 0xA2) - 3. Device ACKs immediately - 4. Poll with STATUS_POLL (0x92) repeatedly - 5. Check state in response to determine completion - - Args: - framed_message: Complete framed message (from build_framed_message). - timeout: Timeout in seconds for the entire operation. - poll_interval: Time between status polls in seconds. - - Returns: - Final status response bytes. - - Raises: - TimeoutError: If timeout waiting for completion. - EL406DeviceError: If device reports an error during the step. - RuntimeError: If device rejects command (NAK). - """ - if self.io is None: - raise RuntimeError("Device not initialized") - - if timeout is None: - timeout = LONG_READ_TIMEOUT - - logger.debug("Starting step command with timeout=%ss", timeout) - - # 1. Wait for device to be ready (not RUNNING) - await self._wait_until_ready(timeout=min(timeout, self.timeout)) - - # 2. Send the step command - logger.debug("Sending step command: %s", framed_message.hex()) - response = await self._send_framed_command(framed_message, timeout=min(timeout, self.timeout)) - logger.debug("Step command sent, got initial response: %s", response.hex()) - - # 3. Initial delay before polling - await asyncio.sleep(0.5) - - # 4. Poll for completion - t0 = time.monotonic() - poll_count = 0 - - logger.debug("Starting polling loop...") - - while time.monotonic() - t0 < timeout: - await asyncio.sleep(poll_interval) - poll_count += 1 - - poll = await self._poll_device_state() - logger.debug("Poll #%d: %d bytes", poll_count, len(poll.raw_response)) - - if poll.state in (STATE_INITIAL, STATE_STOPPED): - logger.debug("Step completed (state=%d) after %d polls", poll.state, poll_count) - if poll.validity != 0: - raise EL406DeviceError(poll.validity, get_error_message(poll.validity)) - return poll.raw_response - - if poll.state == STATE_RUNNING: - logger.debug("Step in progress (state=Running), continuing poll...") - elif poll.state == STATE_PAUSED: - logger.warning("Step is paused (state=3)") - elif poll.status == 0: - # Unknown state with status=0 means done - logger.debug("Done (unknown state=%d, status=0)", poll.state) - return poll.raw_response - else: - logger.debug("Unknown state=%d, status=%d, continuing...", poll.state, poll.status) - - raise TimeoutError(f"Timeout waiting for step completion after {timeout}s") +from pylabrobot.agilent.biotek.el406.driver import ( # noqa: F401 + LONG_READ_TIMEOUT, + DevicePollResult, + EL406Driver as EL406CommunicationMixin, +) diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/enums.py b/pylabrobot/legacy/plate_washing/biotek/el406/enums.py index 8456c4ee576..f0f990b14bc 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/enums.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/enums.py @@ -1,91 +1,13 @@ -"""EL406 enumeration types. +"""EL406 enumeration types — legacy re-export. -This module contains all enumeration types used by the BioTek EL406 -plate washer backend. +Implementation has moved to pylabrobot.agilent.biotek.el406.enums. """ -from __future__ import annotations - -import enum - - -class EL406WasherManifold(enum.IntEnum): - """Washer manifold types.""" - - TUBE_96_DUAL = 0 - TUBE_192 = 1 - TUBE_128 = 2 - TUBE_96_SINGLE = 3 - DEEP_PIN_96 = 4 - NOT_INSTALLED = 255 - - -class EL406SyringeManifold(enum.IntEnum): - """Syringe manifold types.""" - - NOT_INSTALLED = 0 - TUBE_16 = 1 - TUBE_32_LARGE_BORE = 2 - TUBE_32_SMALL_BORE = 3 - TUBE_16_7 = 4 - TUBE_8 = 5 - PLATE_6_WELL = 6 - PLATE_12_WELL = 7 - PLATE_24_WELL = 8 - PLATE_48_WELL = 9 - - -class EL406Sensor(enum.IntEnum): - """Sensor types for the EL406.""" - - VACUUM = 0 # Vacuum sensor - WASTE = 1 # Waste container sensor - FLUID = 2 # Fluid level sensor - FLOW = 3 # Flow sensor - FILTER_VAC = 4 # Filter vacuum sensor - PLATE = 5 # Plate presence sensor - - -class EL406StepType(enum.IntEnum): - """Step types for EL406 operations.""" - - UNDEFINED = 0 - P_DISPENSE = 1 # Peristaltic pump dispense - P_PRIME = 2 # Peristaltic pump prime - P_PURGE = 3 # Peristaltic pump purge - S_DISPENSE = 4 # Syringe dispense - S_PRIME = 5 # Syringe prime - M_WASH = 6 # Manifold wash - M_ASPIRATE = 7 # Manifold aspirate - M_DISPENSE = 8 # Manifold dispense - M_PRIME = 9 # Manifold prime - M_AUTO_CLEAN = 10 # Manifold auto-clean - SHAKE_SOAK = 11 # Shake/soak - - -class EL406Motor(enum.IntEnum): - """Motor types for the EL406.""" - - CARRIER_X = 0 # X-axis plate carrier motor - CARRIER_Y = 1 # Y-axis plate carrier motor - DISP_HEAD_Z = 2 # Dispense head Z-axis motor - WASH_HEAD_Z = 3 # Wash head Z-axis motor - SYRINGE_A = 4 # Syringe pump A motor - SYRINGE_B = 5 # Syringe pump B motor - PERI_PUMP_PRIMARY = 6 # Primary peristaltic pump motor - PERI_PUMP_SECONDARY = 7 # Secondary peristaltic pump motor - LEVEL_SENSE_Y = 8 # Level sense Y-axis motor - WASH_SYRINGE = 9 # Wash syringe motor - WASH_ASP_HEAD_Z = 10 # Wash aspirate head Z-axis motor - SINGLE_WELL_Y = 11 # Single well Y-axis motor - - -class EL406MotorHomeType(enum.IntEnum): - """Motor home types for the EL406.""" - - INIT_ALL_MOTORS = 1 # Initialize all motors - INIT_PERI_PUMP = 2 # Initialize peristaltic pump - HOME_MOTOR = 3 # Home a specific motor - HOME_XYZ_MOTORS = 4 # Home all XYZ motors - VERIFY_MOTOR = 5 # Verify a specific motor position - VERIFY_XYZ_MOTORS = 6 # Verify all XYZ motor positions +from pylabrobot.agilent.biotek.el406.enums import ( # noqa: F401 + EL406Motor, + EL406MotorHomeType, + EL406Sensor, + EL406StepType, + EL406SyringeManifold, + EL406WasherManifold, +) diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/error_codes.py b/pylabrobot/legacy/plate_washing/biotek/el406/error_codes.py index 2cbbc39b504..2b784d80669 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/error_codes.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/error_codes.py @@ -1,247 +1,9 @@ -""" -BioTek EL406 Error Codes - -This module contains error codes for the BioTek EL406 plate washer. +"""BioTek EL406 Error Codes — legacy re-export. -The error codes provide human-readable descriptions for errors that may -occur during communication with the EL406 plate washer. +Implementation has moved to pylabrobot.agilent.biotek.el406.error_codes. """ -ERROR_CODES: dict[int, str] = { - 0x0175: "Error communicating with instrument software. didn't find find park opto sensor transition.", # 373 - 0x0C01: "Requested config/autocal data absent.", # 3073 - 0x0C02: "Calculated checksum didn't match checksum saved.", # 3074 - 0x0C03: "Config parameter out of range.", # 3075 - 0x1001: "Bootcode checksum error at powerup.", # 4097 - 0x1002: "Bootcode error unknown.", # 4098 - 0x1003: "Bootcode page program error.", # 4099 - 0x1004: "Bootcode block size error.", # 4100 - 0x1005: "Bootcode invalid processor signature.", # 4101 - 0x1006: "Bootcode memory exceeded.", # 4102 - 0x1007: "Bootcode invalid slave port.", # 4103 - 0x1008: "Bootcode invalid slave response.", # 4104 - 0x1009: "Bootcode invalid processor detected.", # 4105 - 0x100A: "Bootcode checksum error at powerup.", # 4106 - 0x100B: "Bootcode checksum error at powerup.", # 4107 - 0x100C: "Bootcode checksum error at powerup.", # 4108 - 0x100D: "Bootcode checksum error at powerup.", # 4109 - 0x100E: "Bootcode checksum error at powerup.", # 4110 - 0x100F: "Bootcode checksum error at powerup.", # 4111 - 0x1010: "Bootcode download checksum error.", # 4112 - 0x1250: "UI Processor internal RAM failure.", # 4688 - 0x1251: "MC Processor internal RAM failure.", # 4689 - 0x1300: "Invalid syringe", # 4864 - 0x1301: "Syringe is not connected", # 4865 - 0x1302: "Unable to initialize syringe", # 4866 - 0x1303: "Unable to initialize syringe sensor clear", # 4867 - 0x1304: "Syringe dispense volume out of calibration range", # 4868 - 0x1305: "Invalid syringe operation", # 4869 - 0x1306: "Syringe A FMEA check error", # 4870 - 0x1307: "Syringe B FMEA check error", # 4871 - 0x1355: "The Peri-pump module is not configured", # 4949 - 0x1356: "Invalid Peri-pump dispense position", # 4950 - 0x1357: "The second Peri-pump module is required", # 4951 - 0x1358: "This instrument does not support 0.5 uL Peri-pump dispense volume", # 4952 - 0x1400: "No vacuum pressure detected after turning on the vacuum pump", # 5120 - 0x1401: "The waste bottles must be emptied before continuing", # 5121 - 0x1402: "The valve to be cycle is invalid", # 5122 - 0x1403: "The magnet adapter height is out of range", # 5123 - 0x1404: "Use of the selected plate type is restricted", # 5124 - 0x1405: "Z Axis height error", # 5125 - 0x1406: "Invalid Plate type", # 5126 - 0x1407: "Invalid Step type", # 5127 - 0x1408: "Invalid plate geometry", # 5128 - 0x1409: "Invalid carrier type", # 5129 - 0x140A: "Invalid carrier specified", # 5130 - 0x140B: "Invalid carrier specified", # 5131 - 0x140C: "Invalid carrier specified", # 5132 - 0x140D: "Invalid carrier specified", # 5133 - 0x140E: "Invalid carrier specified", # 5134 - 0x140F: "Invalid carrier specified", # 5135 - 0x1410: "Incompatible hardware configuration", # 5136 - 0x1411: "Invalid carrier specified", # 5137 - 0x1412: "Plate clearance error", # 5138 - 0x1413: "AutoPrime in progress. Please wait until AutoPrime completes.", # 5139 - 0x1414: "AutoPrime is cleaning up. Please wait until AutoPrime cleanup completes.", # 5140 - 0x1415: "An AutoPrime value is out-of-range.", # 5141 - 0x1416: "Vacuum pressure incorrectly detected prior to starting the vacuum pump.", # 5142 - 0x1417: "The autocal sensor was not detected in the back of the instrument.", # 5143 - 0x1430: "Strip washer syringe FMEA check error.", # 5168 - 0x1431: "Strip washer aspirate head not installed.", # 5169 - 0x1432: "Strip washer syringe box not connected.", # 5170 - 0x1433: "Bad step type pointer passed in when finding plate heights.", # 5171 - 0x1500: "There was no buffer fluid present at the start of a manifold-based protocol or at the start of an individual step.", # 5376 - 0x1501: "There was no buffer fluid present immediately before the manifold dispense sequence.", # 5377 - 0x1502: "The buffer valve selection is invalid", # 5378 - 0x1503: "The requested volume to be dispensed through the manifold is smaller than the minimum volume that will be dispensed by the time the DC dispense pump turns on and the dispense valve is opened.", # 5379 - 0x1504: "There was no buffer fluid detected flowing through the manifold tubing during a manifold dispense/prime operation.", # 5380 - 0x1505: "There was no buffer fluid present at the end of a manifold-based protocol or at the end of an individual step.", # 5381 - 0x1506: "The requested carrier Y-axis position is out of range.", # 5382 - 0x1514: "The Ultrasonic Advantage hardware is not configured.", # 5396 - 0x1515: "The low-flow cell-wash hardware is not configured", # 5397 - 0x1516: "Vacuum pressure issue for vacuum filtration", # 5398 - 0x1517: "The software could not read the vacuum filter hardware consistently.", # 5399 - 0x1600: "Ran out of on-board storage space", # 5632 - 0x1601: "Ran out of on-board storage space for P-Dispense steps", # 5633 - 0x1602: "Ran out of on-board storage space for P-Prime steps", # 5634 - 0x1603: "Ran out of on-board storage space for P-Purge steps", # 5635 - 0x1604: "Ran out of on-board storage space for S-Dispense steps", # 5636 - 0x1605: "Ran out of on-board storage space for S-Prime steps", # 5637 - 0x1606: "Ran out of on-board storage space for W-Wash steps", # 5638 - 0x1607: "Ran out of on-board storage space for W-Aspirate steps", # 5639 - 0x1608: "Ran out of on-board storage space for W-Dispense steps", # 5640 - 0x1609: "Ran out of on-board storage space for W-Prime steps", # 5641 - 0x160A: "Ran out of on-board storage space for W-AutoClean steps", # 5642 - 0x160B: "Ran out of on-board storage space for Shake/Soak steps", # 5643 - 0x160C: "Ran out of on-board storage space for 1536 Wash steps", # 5644 - 0x160D: "Invalid Step Type encountered", # 5645 - 0x160E: "Ran out of on-board storage space for P-Purge steps", # 5646 - 0x160F: "Ran out of on-board storage space for P-Purge steps", # 5647 - 0x1610: "Protocol transfer failed.", # 5648 - 0x1700: "Level sensor not installed.", # 5888 - 0x1701: "Level sensor framing error.", # 5889 - 0x1702: "Level sensor timing error.", # 5890 - 0x1703: "Level sensor unknown command.", # 5891 - 0x1704: "Level sensor parameter error.", # 5892 - 0x1705: "Level sensor address error.", # 5893 - 0x1706: "Level sensor error detected but not classified.", # 5894 - 0x1707: "Level sensor response cmd char != request cmd char.", # 5895 - 0x1708: "Level sensor command response not long enough.", # 5896 - 0x1709: "Level sensor command response address not equal to '0'.", # 5897 - 0x170A: "Level sensor command response checksum error.", # 5898 - 0x170B: "Level sensor timeout while looking for SOF char.", # 5899 - 0x170C: "Level sensor RX error - framing error.", # 5900 - 0x170D: "Level sensor RX error in Mode parameter.", # 5901 - 0x170E: "Level sensor RX error in Format parameter.", # 5902 - 0x170F: "Level sensor RX error in Sensitivity parameter.", # 5903 - 0x1710: "Level sensor RX error in Average parameter.", # 5904 - 0x1711: "Level sensor RX error in Temp Comp parameter.", # 5905 - 0x1712: "Level sensor RX error in SDC parameter.", # 5906 - 0x1713: "Level sensor RX error in SDE parameter.", # 5907 - 0x1714: "Level sensor RX error in setting configuration.", # 5908 - 0x1715: "Level sensor error in converting a read to a level.", # 5909 - 0x1716: "7 reads did not come up with at least 3 good ones.", # 5910 - 0x1717: "Level sensor echo range error.", # 5911 - 0x1718: "Level sensor echo width error.", # 5912 - 0x1719: "7 reads did not come up with at least 3 good ones.", # 5913 - 0x171A: "Level sensor - motor axis incorrect in FindAxisCenter().", # 5914 - 0x171B: "7 reads did not come up with at least 3 good ones.", # 5915 - 0x171C: "In FindAxisCenter() initial read not > threshold.", # 5916 - 0x171D: "7 reads did not come up with at least 3 good ones.", # 5917 - 0x171E: "Level sensor - no well edge found - reached step limit.", # 5918 - 0x171F: "Level sensor - repeated FindAxisCenter() did not converge.", # 5919 - 0x1720: "Level sensor corner cal memory checksum error.", # 5920 - 0x1721: "Level sensor A1 cal memory checksum error.", # 5921 - 0x1722: "Level sensor - carrier height wrong - plate test > 30mm.", # 5922 - 0x1723: "A plate read was started but not finished successfully.", # 5923 - 0x1724: "7 reads did not come up with at least 3 good ones.", # 5924 - 0x1725: "The range of the smallest 3 reads (of 7) was > 0.5mm.", # 5925 - 0x1726: "Input to McReqLvlSnsZPosn() out of range.", # 5926 - 0x1727: "The correction factor is out of range.", # 5927 - 0x1728: "7 reads did not come up with at least 3 good ones.", # 5928 - 0x1729: "FindLsyParkPosn() could not find the park position.", # 5929 - 0x172A: "Read Plate or Read One command to MC - invalid Read Type.", # 5930 - 0x172B: "Row or column was 0 - must start at 1.", # 5931 - 0x172C: "Well test error - previous config not loaded.", # 5932 - 0x172D: "Well test error - wrong well.", # 5933 - 0x172E: "7 reads did not come up with at least 3 good ones.", # 5934 - 0x172F: "7 reads did not come up with at least 3 good ones.", # 5935 - 0x1730: "7 reads did not come up with at least 3 good ones.", # 5936 - 0x1731: "7 reads did not come up with at least 3 good ones.", # 5937 - 0x1732: "Level sensor - config memory checksum error.", # 5938 - 0x1733: "Well positions have not been calculated.", # 5939 - 0x1734: "Level sense correction factor not been calculated.", # 5940 - 0x1735: "Doing a Carrier Test - no previous Z-Axis cal data in EEPROM.", # 5941 - 0x1736: "Attempted a Z-axis wash head move with Sensor Y not at park posn.", # 5942 - 0x1737: "Plate test did not find a plate.", # 5943 - 0x1738: "Level sensor - config memory checksum error.", # 5944 - 0x1739: "MC Not all level sensor cal and config data has been loaded.", # 5945 - 0x173A: "Level sensor transmission buffer should be empty before sending a command.", # 5946 - 0x173B: "Level sensor - Z-Cal, Z=0, current to cal > +/-0.75mm.", # 5947 - 0x173C: "Level sensor - Z-Cal, Z=0, factory cal < 23mm or > 29mm.", # 5948 - 0x173D: "Level sensor - Z-Cal, Z=0, post to pre > +/-0.3mm.", # 5949 - 0x173E: "Level sensor - Z-Cal, Z=0, < 15.0mm.", # 5950 - 0x173F: "7 reads did not produce at least 6 good ones.", # 5951 - 0x6100: "The Mini-Tube plate must be used with the Mini-Tube Carrier.", # 24832 - 0x6101: "The 405 TS does not support downloading basecode from the LHC.", # 24833 - 0x6102: "The Mini-Tube plate must be used with the Mini-Tube Carrier.", # 24834 - 0x6110: "The Verify Manifold Test input parameters file was not found.", # 24848 - 0x6111: "The user data file for the Verify Manifold Test could not be read in.", # 24849 - 0x6112: "The Verify Manifold Test was stopped by user.", # 24850 - 0x6113: "The Verify Manifold Test is not supported.", # 24851 - 0x6114: "Invalid well specified.", # 24852 - 0x6115: "Invalid well specified.", # 24853 - 0x6116: "Invalid well specified.", # 24854 - 0x6117: "Invalid well specified.", # 24855 - 0x6118: "Invalid well specified.", # 24856 - 0x6119: "Invalid well specified.", # 24857 - 0x611A: "Invalid well specified.", # 24858 - 0x611B: "Invalid well specified.", # 24859 - 0x611C: "Invalid well specified.", # 24860 - 0x611D: "Invalid well specified.", # 24861 - 0x611E: "Invalid well specified.", # 24862 - 0x611F: "Invalid well specified.", # 24863 - 0x6120: "The carrier is not level.", # 24864 - 0x6121: "The test had an aspirate scan error.", # 24865 - 0x6122: "The test had an dispense scan error.", # 24866 - 0x6123: "Center of well not found where expected for Verify test plate.", # 24867 - 0x6124: "Incorrect plate installed for Verify test.", # 24868 - 0x6125: "The well volume following an aspirate indicates insufficient aspiration.", # 24869 - 0x6126: "The well volume following a dispense indicates insufficient dispense.", # 24870 - 0x6127: "Scan data could not be returned from the instrument.", # 24871 - 0x6128: "Invalid well specified.", # 24872 - 0x6129: "This Verify Manifold Test step was not performed.", # 24873 - 0x6150: "The mean Dispense Volume is out of range.", # 24912 - 0x6151: "The Dispense CV % exceeds the maximum threshold.", # 24913 - 0x6152: "The Aspirate Rate is below the minimum threshold.", # 24914 - 0x6160: "This step requires Washer components to be installed and connected.", # 24928 - 0x6161: "The Strip Washer Manifold and the Plate Type are incompatible.", # 24929 - 0x6162: "The Strip Washer does not support this Plate Type", # 24930 - 0x6165: "This Peri-pump does not support single well dispensing.", # 24933 - 0x6166: "The instrument does not support single well dispensing.", # 24934 - 0x6167: "The Syringe Manifold can only be used with 6-well plates", # 24935 - 0x6168: "The Syringe Manifold can only be used with 12-well plates", # 24936 - 0x6169: "The Syringe Manifold can only be used with 24-well plates", # 24937 - 0x6170: "The Syringe Manifold can only be used with 48-well plates", # 24944 - 0x6171: "The Cassette for single well dispensing does not support this plate type", # 24945 - 0x8100: "Error communicating with instrument software. Message not acknowledged (NAK).", # 33024 - 0x8101: "Error communicating with instrument software. Timeout while waiting for serial message data.", # 33025 - 0x8102: "Error communicating with instrument software. Instrument busy and unable to process message.", # 33026 - 0x8103: "Error communicating with instrument software. Receive buffer overflow error.", # 33027 - 0x8104: "Error communicating with instrument software. Communication checksum error.", # 33028 - 0x8105: "Error communicating with instrument software. Invalid structure type in byMsgStructure header field.", # 33029 - 0x8106: "Error communicating with instrument software. Invalid destination in byMsgDestination header field.", # 33030 - 0x8107: "Error communicating with instrument software. Message sent to instrument is not supported.", # 33031 - 0x8108: "Error communicating with instrument software. Message body size exceeds max limit.", # 33032 - 0x8109: "Error communicating with instrument software. Max number of requests currently running and cannot run the latest request.", # 33033 - 0x810A: "Error communicating with instrument software. No request running when response request issued.", # 33034 - 0x810B: "Error communicating with instrument software. Receive buffer overflow error.", # 33035 - 0x810C: "Error communicating with instrument software. Response for outstanding request not ready yet.", # 33036 - 0x810D: "Error communicating with instrument software. To communicate, the instrument must be at the Main Menu.", # 33037 - 0x810E: "Error communicating with instrument software. One or more request parameters are not valid.", # 33038 - 0x810F: "Error communicating with instrument software. Command not valid in current state.", # 33039 - 0xA100: " not available.", # 41216 - 0xA101: " not available.", # 41217 - 0xA102: " not available.", # 41218 - 0xA103: " not available.", # 41219 - 0xA104: " not available.", # 41220 - 0xA300: " power supply level error.", # 41728 - 0xA301: "+5v logic power supply level error.", # 41729 - 0xA302: "+24v system/motor power supply level error.", # 41730 - 0xA303: "Internal +42v PeriPump power supply level error.", # 41731 - 0xA304: "Internal reference voltage error.", # 41732 - 0xA305: "External +42v PeriPump power supply level error.", # 41733 -} - - -def get_error_message(code: int) -> str: - """ - Get the error message for a given error code. - - Args: - code: The error code to look up. - - Returns: - The error message, or a default message if not found. - """ - return ERROR_CODES.get(code, f"Unknown error code: 0x{code:04X} ({code})") +from pylabrobot.agilent.biotek.el406.error_codes import ( # noqa: F401 + ERROR_CODES, + get_error_message, +) diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/errors.py b/pylabrobot/legacy/plate_washing/biotek/el406/errors.py index 12bbcab09dc..0f9b23ba96a 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/errors.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/errors.py @@ -1,47 +1,9 @@ -"""EL406 exception classes. +"""EL406 exception classes — legacy re-export. -This module contains exception classes used by the BioTek EL406 -plate washer backend. +Implementation has moved to pylabrobot.agilent.biotek.el406.errors. """ -from __future__ import annotations - - -class EL406CommunicationError(Exception): - """Exception raised for FTDI/USB communication errors with the EL406. - - This exception is raised when low-level communication fails, such as: - - USB device disconnected - - FTDI driver errors - - Write/read failures - - Attributes: - operation: The operation that failed (e.g., "write", "read", "open"). - original_error: The underlying exception that caused this error. - """ - - def __init__( - self, - message: str, - operation: str = "", - original_error: Exception | None = None, - ) -> None: - super().__init__(message) - self.operation = operation - self.original_error = original_error - - -class EL406DeviceError(Exception): - """Exception raised when the EL406 device reports an error via the validity field. - - The device returns a non-zero validity code in the status poll response - when a step command fails (e.g., no buffer fluid, invalid syringe, hardware fault). - - Attributes: - error_code: The raw error code from the device (e.g., 0x1500). - message: Human-readable error description. - """ - - def __init__(self, error_code: int, message: str) -> None: - self.error_code = error_code - super().__init__(f"EL406 error 0x{error_code:04X}: {message}") +from pylabrobot.agilent.biotek.el406.errors import ( # noqa: F401 + EL406CommunicationError, + EL406DeviceError, +) diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/helpers.py b/pylabrobot/legacy/plate_washing/biotek/el406/helpers.py index cdac55fcf13..84cb91599ec 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/helpers.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/helpers.py @@ -1,104 +1,13 @@ -"""EL406 plate type defaults and helper functions.""" - -from __future__ import annotations - -from pylabrobot.resources import Plate - -# Threshold for distinguishing standard-height vs low-profile plates (in mm). -# Standard microplates are ~14mm tall; PCR/flanged plates are typically <12mm. -_LOW_PROFILE_THRESHOLD_MM = 12.0 - -# Wire byte → physical defaults for each EL406 plate format. -# Keys are the raw byte values sent on the wire protocol. -_WIRE_BYTE_DEFAULTS: dict[int, dict[str, int]] = { - 0: { # 1536-well standard - "dispenser_height": 250, - "dispense_z": 94, - "aspirate_z": 42, - "rows": 32, - "cols": 48, - }, - 1: { # 384-well standard - "dispenser_height": 333, - "dispense_z": 120, - "aspirate_z": 22, - "rows": 16, - "cols": 24, - }, - 2: { # 384-well PCR (low profile) - "dispenser_height": 230, - "dispense_z": 83, - "aspirate_z": 2, - "rows": 16, - "cols": 24, - }, - 4: { # 96-well - "dispenser_height": 336, - "dispense_z": 121, - "aspirate_z": 29, - "rows": 8, - "cols": 12, - }, - 14: { # 1536-well flanged (low profile) - "dispenser_height": 196, - "dispense_z": 93, - "aspirate_z": 13, - "rows": 32, - "cols": 48, - }, -} - - -def plate_to_wire_byte(plate: Plate) -> int: - """Resolve a PLR Plate to the EL406 wire protocol byte. - - Determines the format from well count, and uses plate height (``size_z``) - to distinguish standard vs low-profile variants for 384 and 1536 plates. - - Args: - plate: A PyLabRobot Plate resource. - - Returns: - Integer byte value for the EL406 wire protocol. - - Raises: - ValueError: If the plate well count is not 96, 384, or 1536. - """ - wells = plate.num_items - if wells == 96: - return 4 - if wells == 384: - return 2 if plate.get_size_z() < _LOW_PROFILE_THRESHOLD_MM else 1 - if wells == 1536: - return 14 if plate.get_size_z() < _LOW_PROFILE_THRESHOLD_MM else 0 - raise ValueError(f"Unsupported plate well count: {wells}. EL406 supports 96, 384, or 1536.") - - -def plate_defaults(plate: Plate) -> dict[str, int]: - """Return the physical defaults dict for a plate.""" - return _WIRE_BYTE_DEFAULTS[plate_to_wire_byte(plate)] - - -def plate_max_columns(plate: Plate) -> int: - """Return the number of columns for a plate.""" - return plate.num_items_x - - -def plate_max_row_groups(plate: Plate) -> int: - """Return the number of row groups for a plate. - - 96-well: 1 row group (no row selection). - 384-well: 2 row groups. - 1536-well: 4 row groups. - """ - return {12: 1, 24: 2, 48: 4}[plate.num_items_x] - - -def plate_well_count(plate: Plate) -> int: - """Return the well count for a plate (96, 384, or 1536).""" - return plate.num_items - - -def plate_default_z(plate: Plate) -> int: - """Return the default dispenser Z height for a plate.""" - return plate_defaults(plate)["dispenser_height"] +"""EL406 plate type defaults and helper functions — legacy re-export. + +Implementation has moved to pylabrobot.agilent.biotek.el406.helpers. +""" + +from pylabrobot.agilent.biotek.el406.helpers import ( # noqa: F401 + plate_default_z, + plate_defaults, + plate_max_columns, + plate_max_row_groups, + plate_to_wire_byte, + plate_well_count, +) diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/helpers_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/helpers_tests.py index b532057aed6..fb2f08edabf 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/helpers_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/helpers_tests.py @@ -41,7 +41,7 @@ def setUp(self): def test_encode_volume_little_endian(self): """Volume should be encoded as little-endian 2 bytes.""" - cmd = self.backend._build_dispense_command( + cmd = self.backend._plate_washing._build_dispense_command( plate=PT96, volume=1000.0, buffer="A", @@ -55,7 +55,7 @@ def test_encode_volume_little_endian(self): def test_encode_signed_byte_positive(self): """Positive offset should encode correctly.""" - cmd = self.backend._build_aspirate_command( + cmd = self.backend._plate_washing._build_aspirate_command( plate=PT96, time_value=1000, travel_rate_byte=3, @@ -68,7 +68,7 @@ def test_encode_signed_byte_positive(self): def test_encode_signed_byte_negative(self): """Negative offset should encode as two's complement.""" - cmd = self.backend._build_aspirate_command( + cmd = self.backend._plate_washing._build_aspirate_command( plate=PT96, time_value=1000, travel_rate_byte=3, diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/protocol.py b/pylabrobot/legacy/plate_washing/biotek/el406/protocol.py index 8ec638c0bfc..46873fc8928 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/protocol.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/protocol.py @@ -1,107 +1,10 @@ -"""EL406 protocol framing utilities. +"""EL406 protocol framing utilities — legacy re-export. -This module contains the protocol framing functions for building -properly formatted messages for the BioTek EL406 plate washer. +Implementation has moved to pylabrobot.agilent.biotek.el406.protocol. """ -from __future__ import annotations - -from pylabrobot.io.binary import Writer - - -def build_framed_message(command: int, data: bytes = b"") -> bytes: - """Build a properly framed EL406 message. - - Protocol structure: - [0]: 0x01 (start marker) - [1]: 0x02 (version marker) - [2-3]: command (little-endian short) - [4]: 0x01 (constant) - [5-6]: reserved (ushort, typically 0) - [7-8]: data length (ushort, little-endian) - [9-10]: checksum (ushort, little-endian) - ... followed by data bytes - - Checksum is two's complement of sum of header bytes 0-8 + all data bytes. - - Args: - command: 16-bit command code - data: Optional data bytes - - Returns: - Complete framed message with header and checksum - """ - # Build header bytes 0-8 (checksum placeholder filled after) - header_prefix = ( - Writer() - .u8(0x01) # [0] Start marker - .u8(0x02) # [1] Version marker - .u16(command) # [2-3] Command (LE) - .u8(0x01) # [4] Constant - .u16(0x0000) # [5-6] Reserved - .u16(len(data)) # [7-8] Data length (LE) - .finish() - ) # fmt: skip - - # Checksum: two's complement of sum of header bytes 0-8 + all data bytes - checksum_sum = sum(header_prefix) + sum(data) - checksum = (0xFFFF - checksum_sum + 1) & 0xFFFF - - return header_prefix + Writer().u16(checksum).finish() + data - - -def encode_column_mask(columns: list[int] | None) -> bytes: - """Encode list of column indices to 6-byte (48-bit) column mask. - - Each bit represents one column: 0 = skip, 1 = operate on column. - - Args: - columns: List of column indices (0-47) to select, or None for all columns. - If None, returns all 1s (all columns selected). - If empty list, returns all 0s (no columns selected). - - Returns: - 6 bytes representing the 48-bit column mask in little-endian order. - - Raises: - ValueError: If any column index is out of range (not 0-47). - """ - if columns is None: - return bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]) - - for col in columns: - if col < 0 or col > 47: - raise ValueError(f"Column index {col} out of range. Must be 0-47.") - - mask = [0] * 6 - for col in columns: - byte_index = col // 8 - bit_index = col % 8 - mask[byte_index] |= 1 << bit_index - - return bytes(mask) - - -def columns_to_column_mask(columns: list[int] | None, plate_wells: int = 96) -> list[int] | None: - """Convert 1-indexed column numbers to 0-indexed column indices. - - Args: - columns: List of column numbers (1-based), or None for all columns. - plate_wells: Plate format (96, 384, 1536). Determines max columns. - - Returns: - List of 0-indexed column indices, or None if columns is None. - - Raises: - ValueError: If column numbers are out of range. - """ - if columns is None: - return None - - max_cols = {96: 12, 384: 24, 1536: 48}.get(plate_wells, 48) - indices = [] - for col in columns: - if col < 1 or col > max_cols: - raise ValueError(f"Column {col} out of range for {plate_wells}-well plate (1-{max_cols}).") - indices.append(col - 1) - return indices +from pylabrobot.agilent.biotek.el406.protocol import ( # noqa: F401 + build_framed_message, + columns_to_column_mask, + encode_column_mask, +) diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/queries.py b/pylabrobot/legacy/plate_washing/biotek/el406/queries.py index 5daf5c1ecea..ba61be1fdfa 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/queries.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/queries.py @@ -1,185 +1,10 @@ -"""EL406 query methods. +"""EL406 query methods — legacy re-export. -This module contains the mixin class for query operations on the -BioTek EL406 plate washer. +Implementation has moved to pylabrobot.agilent.biotek.el406.driver. """ -from __future__ import annotations +from pylabrobot.agilent.biotek.el406.driver import EL406Driver -import enum -import logging -from typing import TypedDict, TypeVar - -from .communication import LONG_READ_TIMEOUT -from .enums import ( - EL406Sensor, - EL406SyringeManifold, - EL406WasherManifold, -) - -logger = logging.getLogger(__name__) - -_E = TypeVar("_E", bound=enum.Enum) - - -class SyringeBoxInfo(TypedDict): - box_type: int - box_size: int - installed: bool - - -class SelfCheckResult(TypedDict): - success: bool - error_code: int - message: str - - -class InstrumentSettings(TypedDict): - washer_manifold: EL406WasherManifold - syringe_manifold: EL406SyringeManifold - syringe_box: SyringeBoxInfo - peristaltic_pump_1: bool - peristaltic_pump_2: bool - - -class EL406QueriesMixin: - """Mixin providing query methods for the EL406. - - This mixin provides: - - Manifold queries (washer, syringe) - - Serial number query - - Sensor status query - - Syringe box info query - - Peristaltic pump installation query - - Instrument settings query - - Self-check query - - Requires: - self._send_framed_query: Async method for sending framed queries - """ - - async def _send_framed_query( - self, - command: int, - data: bytes = b"", - timeout: float | None = None, - ) -> bytes: - raise NotImplementedError - - @staticmethod - def _extract_payload_byte(response_data: bytes) -> int: - """Extract the first payload byte, handling optional 2-byte header prefix.""" - return response_data[2] if len(response_data) > 2 else response_data[0] - - async def _query_enum(self, command: int, enum_cls: type[_E], label: str) -> _E: - """Send a framed query and parse the response byte as an *enum_cls* member.""" - logger.info("Querying %s", label) - response_data = await self._send_framed_query(command) - logger.debug("%s response data: %s", label.capitalize(), response_data.hex()) - value_byte = self._extract_payload_byte(response_data) - - try: - result = enum_cls(value_byte) - except ValueError: - logger.warning("Unknown %s: %d (0x%02X)", label, value_byte, value_byte) - raise ValueError( - f"Unknown {label}: {value_byte} (0x{value_byte:02X}). " - f"Valid types: {[m.name for m in enum_cls]}" - ) from None - - logger.info("%s: %s (0x%02X)", label.capitalize(), result.name, result.value) - return result - - async def request_washer_manifold(self) -> EL406WasherManifold: - """Query the installed washer manifold type.""" - return await self._query_enum( - command=0xD8, enum_cls=EL406WasherManifold, label="washer manifold type" - ) - - async def request_syringe_manifold(self) -> EL406SyringeManifold: - """Query the installed syringe manifold type.""" - return await self._query_enum( - command=0xBB, enum_cls=EL406SyringeManifold, label="syringe manifold type" - ) - - async def request_serial_number(self) -> str: - """Query the product serial number.""" - logger.info("Querying product serial number") - response_data = await self._send_framed_query(command=0x0100) - serial_number = response_data[2:].decode("ascii", errors="ignore").strip().rstrip("\x00") - logger.info("Product serial number: %s", serial_number) - return serial_number - - async def request_sensor_enabled(self, sensor: EL406Sensor) -> bool: - """Query whether a specific sensor is enabled.""" - logger.info("Querying sensor enabled status: %s", sensor.name) - response_data = await self._send_framed_query(command=0xD2, data=bytes([sensor.value])) - logger.debug("Sensor enabled response data: %s", response_data.hex()) - enabled = bool(self._extract_payload_byte(response_data)) - logger.info("Sensor %s enabled: %s", sensor.name, enabled) - return enabled - - async def request_syringe_box_info(self) -> SyringeBoxInfo: - """Get syringe box information.""" - logger.info("Querying syringe box info") - response_data = await self._send_framed_query(command=0xF6) - logger.debug("Syringe box info response data: %s", response_data.hex()) - - box_type = self._extract_payload_byte(response_data) - box_size = ( - response_data[3] - if len(response_data) > 3 - else (response_data[1] if len(response_data) > 1 else 0) - ) - installed = box_type != 0 - - info = SyringeBoxInfo(box_type=box_type, box_size=box_size, installed=installed) - logger.info("Syringe box info: %s", info) - return info - - async def request_peristaltic_installed(self, selector: int) -> bool: - """Check if a peristaltic pump is installed.""" - if selector < 0 or selector > 1: - raise ValueError(f"Invalid selector {selector}. Must be 0 (primary) or 1 (secondary).") - - logger.info("Querying peristaltic pump installed: selector=%d", selector) - response_data = await self._send_framed_query(command=0x0104, data=bytes([selector])) - logger.debug("Peristaltic installed response data: %s", response_data.hex()) - - installed = bool(self._extract_payload_byte(response_data)) - - logger.info("Peristaltic pump %d installed: %s", selector, installed) - return installed - - async def request_instrument_settings(self) -> InstrumentSettings: - """Get current instrument hardware configuration.""" - logger.info("Querying instrument settings from hardware") - - washer_manifold = await self.request_washer_manifold() - syringe_manifold = await self.request_syringe_manifold() - syringe_box = await self.request_syringe_box_info() - peristaltic_1 = await self.request_peristaltic_installed(0) - peristaltic_2 = await self.request_peristaltic_installed(1) - - settings = InstrumentSettings( - washer_manifold=washer_manifold, - syringe_manifold=syringe_manifold, - syringe_box=syringe_box, - peristaltic_pump_1=peristaltic_1, - peristaltic_pump_2=peristaltic_2, - ) - logger.info("Instrument settings: %s", settings) - return settings - - async def run_self_check(self) -> SelfCheckResult: - """Run instrument self-check diagnostics.""" - logger.info("Running instrument self-check") - response_data = await self._send_framed_query(command=0x95, timeout=LONG_READ_TIMEOUT) - logger.debug("Self-check response data: %s", response_data.hex()) - error_code = self._extract_payload_byte(response_data) - success = error_code == 0 - - message = "Self-check passed" if success else f"Self-check failed (error code: {error_code})" - result = SelfCheckResult(success=success, error_code=error_code, message=message) - logger.info("Self-check result: %s", result["message"]) - return result +InstrumentSettings = EL406Driver.InstrumentSettings +SelfCheckResult = EL406Driver.SelfCheckResult +SyringeBoxInfo = EL406Driver.SyringeBoxInfo diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps/__init__.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/__init__.py index 07224cd005b..9f86bc222cb 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/steps/__init__.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps/__init__.py @@ -1,33 +1,9 @@ -"""EL406 protocol step methods. +"""EL406 protocol step methods — legacy wrapper. -This package contains the mixin class for protocol step operations on the -BioTek EL406 plate washer (prime, dispense, aspirate, wash, shake, etc.). - -The methods are split into per-subsystem modules for maintainability, but -the composed ``EL406StepsMixin`` class is the only public API. +All step methods have been migrated to the capability architecture. +This module is kept for backward compatibility. """ -from ._manifold import EL406ManifoldStepsMixin -from ._peristaltic import EL406PeristalticStepsMixin -from ._shake import EL406ShakeStepsMixin -from ._syringe import EL406SyringeStepsMixin - - -class EL406StepsMixin( - EL406PeristalticStepsMixin, - EL406SyringeStepsMixin, - EL406ManifoldStepsMixin, - EL406ShakeStepsMixin, -): - """Mixin providing all protocol step methods for the EL406. - - This class composes all per-subsystem step mixins: - - Peristaltic: peristaltic_prime, peristaltic_dispense, peristaltic_purge - - Syringe: syringe_dispense, syringe_prime - - Manifold: manifold_aspirate, manifold_dispense, manifold_wash, manifold_prime, manifold_auto_clean - - Shake: shake - Requires: - self._send_step_command: Async method for sending framed commands - self.timeout: Default timeout in seconds - """ +class EL406StepsMixin: + """All step methods have been migrated to capability backends.""" diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_base.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_base.py deleted file mode 100644 index b94292144dd..00000000000 --- a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_base.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Base mixin providing type stubs for EL406 step sub-mixins. - -Sub-mixins inherit from this class so they can reference -``self._send_step_command`` and ``self.timeout`` without circular imports. -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from pylabrobot.resources import Plate - - -class EL406StepsBaseMixin: - """Type stubs consumed by the per-subsystem step mixins.""" - - timeout: float - - if TYPE_CHECKING: - - async def _send_step_command( - self, - framed_message: bytes, - timeout: float | None = None, - ) -> bytes: ... - - @asynccontextmanager - async def batch(self, plate: Plate) -> AsyncIterator[None]: - yield diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_manifold.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_manifold.py index 434e62409d6..147c4d0415d 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_manifold.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_manifold.py @@ -1,1414 +1,4 @@ -"""EL406 manifold step methods. +"""EL406 manifold step methods — legacy wrapper. -Provides manifold_aspirate, manifold_dispense, manifold_wash, manifold_prime, -and manifold_auto_clean operations plus their corresponding command builders. +Implementation has moved to pylabrobot.agilent.biotek.el406.plate_washing_backend. """ - -from __future__ import annotations - -import logging -from typing import Literal - -from pylabrobot.io.binary import Writer -from pylabrobot.resources import Plate - -from ..helpers import plate_defaults, plate_to_wire_byte -from ..protocol import build_framed_message -from ._base import EL406StepsBaseMixin -from ._shake import INTENSITY_TO_BYTE, Intensity, validate_intensity - -logger = logging.getLogger(__name__) - -Buffer = Literal["A", "B", "C", "D"] -TravelRate = Literal["1", "2", "3", "4", "5", "1 CW", "2 CW", "3 CW", "4 CW", "6 CW"] - -TRAVEL_RATE_TO_BYTE: dict[str, int] = { - "1": 1, - "2": 2, - "3": 3, - "4": 4, - "5": 5, - "1 CW": 7, - "2 CW": 8, - "3 CW": 9, - "4 CW": 10, - "6 CW": 6, -} - - -def travel_rate_to_byte(rate: TravelRate) -> int: - if rate not in TRAVEL_RATE_TO_BYTE: - valid = sorted(TRAVEL_RATE_TO_BYTE.keys()) - raise ValueError( - f"Invalid travel rate '{rate}'. Must be one of: {', '.join(repr(r) for r in valid)}" - ) - return TRAVEL_RATE_TO_BYTE[rate] - - -def get_plate_wash_defaults(plate: Plate) -> dict: - pt = plate_defaults(plate) - return { - "dispense_volume": 300.0 if pt["cols"] == 12 else 100.0, - "dispense_z": pt["dispense_z"], - "aspirate_z": pt["aspirate_z"], - } - - -def validate_buffer(buffer: Buffer) -> None: - if buffer.upper() not in {"A", "B", "C", "D"}: - raise ValueError(f"Invalid buffer '{buffer}'. Must be one of: A, B, C, D") - - -def validate_flow_rate(flow_rate: int) -> None: - if not 1 <= flow_rate <= 9: - raise ValueError(f"Invalid flow rate {flow_rate}. Must be between 1 and 9.") - - -def validate_cycles(cycles: int) -> None: - if not 1 <= cycles <= 250: - raise ValueError(f"cycles must be 1-250, got {cycles}") - - -def validate_delay_ms(delay_ms: int) -> None: - if not 0 <= delay_ms <= 65535: - raise ValueError(f"delay_ms must be 0-65535, got {delay_ms}") - - -def validate_travel_rate(rate: int) -> None: - if not 1 <= rate <= 9: - raise ValueError(f"travel_rate must be 1-9, got {rate}") - - -class EL406ManifoldStepsMixin(EL406StepsBaseMixin): - """Mixin for manifold step operations.""" - - @staticmethod - def _validate_manifold_xy(x: int, y: int, label: str) -> None: - """Validate manifold X/Y offsets (X: -60..60, Y: -40..40).""" - if not -60 <= x <= 60: - raise ValueError(f"{label} X offset must be -60..60, got {x}") - if not -40 <= y <= 40: - raise ValueError(f"{label} Y offset must be -40..40, got {y}") - - @staticmethod - def _validate_aspirate_mode_params( - vacuum_filtration: bool, - travel_rate: TravelRate, - delay_ms: int, - vacuum_time_sec: int, - ) -> tuple[int, int]: - """Validate aspirate mode-specific params and return (time_value, rate_byte).""" - if not vacuum_filtration: - if travel_rate not in TRAVEL_RATE_TO_BYTE: - raise ValueError( - f"Invalid travel rate '{travel_rate}'. Must be one of: " - f"{', '.join(repr(r) for r in sorted(TRAVEL_RATE_TO_BYTE))}" - ) - if not 0 <= delay_ms <= 5000: - raise ValueError(f"Aspirate delay must be 0-5000 ms, got {delay_ms}") - return (delay_ms, travel_rate_to_byte(travel_rate)) - - if not 5 <= vacuum_time_sec <= 999: - raise ValueError(f"Vacuum filtration time must be 5-999 seconds, got {vacuum_time_sec}") - return (vacuum_time_sec, travel_rate_to_byte("3")) - - @classmethod - def _validate_aspirate_offsets( - cls, - offset_x: int, - offset_y: int, - offset_z: int, - secondary_aspirate: bool, - secondary_x: int, - secondary_y: int, - secondary_z: int, - ) -> None: - """Validate aspirate XYZ offset ranges (primary and secondary).""" - cls._validate_manifold_xy(offset_x, offset_y, "Aspirate") - if not 1 <= offset_z <= 210: - raise ValueError(f"Aspirate Z offset must be 1-210, got {offset_z}") - if secondary_aspirate: - cls._validate_manifold_xy(secondary_x, secondary_y, "Secondary") - if not 1 <= secondary_z <= 210: - raise ValueError(f"Secondary Z offset must be 1-210, got {secondary_z}") - - def _validate_aspirate_params( - self, - plate: Plate, - vacuum_filtration: bool, - travel_rate: TravelRate, - delay_ms: int, - vacuum_time_sec: int, - offset_x: int, - offset_y: int, - offset_z: int | None, - secondary_aspirate: bool, - secondary_x: int, - secondary_y: int, - secondary_z: int | None, - ) -> tuple[int, int, int, int]: - """Validate aspirate parameters and resolve plate-type defaults. - - Returns: - (offset_z, secondary_z, time_value, rate_byte) - """ - pt_defaults = get_plate_wash_defaults(plate) - if offset_z is None: - offset_z = pt_defaults["aspirate_z"] - if secondary_z is None: - secondary_z = pt_defaults["aspirate_z"] - - time_value, rate_byte = self._validate_aspirate_mode_params( - vacuum_filtration, - travel_rate, - delay_ms, - vacuum_time_sec, - ) - self._validate_aspirate_offsets( - offset_x, - offset_y, - offset_z, - secondary_aspirate, - secondary_x, - secondary_y, - secondary_z, - ) - return (offset_z, secondary_z, time_value, rate_byte) - - @staticmethod - def _validate_dispense_extras( - pre_dispense_volume: float, - pre_dispense_flow_rate: int, - vacuum_delay_volume: float, - ) -> None: - """Validate pre-dispense and vacuum-delay parameters for manifold dispense.""" - if pre_dispense_volume != 0 and not 25 <= pre_dispense_volume <= 3000: - raise ValueError( - f"Manifold pre-dispense volume must be 0 (disabled) or 25-3000 uL, " - f"got {pre_dispense_volume}" - ) - if not 3 <= pre_dispense_flow_rate <= 11: - raise ValueError( - f"Manifold pre-dispense flow rate must be 3-11, got {pre_dispense_flow_rate}" - ) - if not 0 <= vacuum_delay_volume <= 3000: - raise ValueError(f"Manifold vacuum delay volume must be 0-3000 uL, got {vacuum_delay_volume}") - - def _validate_dispense_params( - self, - plate: Plate, - volume: float, - buffer: Buffer, - flow_rate: int, - offset_x: int, - offset_y: int, - offset_z: int | None, - pre_dispense_volume: float, - pre_dispense_flow_rate: int, - vacuum_delay_volume: float, - ) -> int: - """Validate dispense parameters and resolve plate-type defaults. - - Returns: - Resolved offset_z. - """ - if offset_z is None: - pt_defaults = get_plate_wash_defaults(plate) - offset_z = pt_defaults["dispense_z"] - - if not 25 <= volume <= 3000: - raise ValueError(f"Manifold dispense volume must be 25-3000 uL, got {volume}") - validate_buffer(buffer) - if not 1 <= flow_rate <= 11: - raise ValueError(f"Manifold dispense flow rate must be 1-11, got {flow_rate}") - if flow_rate <= 2 and vacuum_delay_volume <= 0: - raise ValueError( - f"Flow rates 1-2 (cell wash) require vacuum_delay_volume > 0, " - f"got flow_rate={flow_rate} with vacuum_delay_volume={vacuum_delay_volume}" - ) - self._validate_manifold_xy(offset_x, offset_y, "Manifold dispense") - if not 1 <= offset_z <= 210: - raise ValueError(f"Manifold dispense Z offset must be 1-210, got {offset_z}") - self._validate_dispense_extras(pre_dispense_volume, pre_dispense_flow_rate, vacuum_delay_volume) - - return offset_z - - def _resolve_wash_defaults( - self, - plate: Plate, - dispense_volume: float | None, - dispense_z: int | None, - aspirate_z: int | None, - secondary_z: int | None, - final_secondary_z: int | None, - ) -> tuple[float, int, int, int, int]: - """Resolve plate-type-aware defaults for wash parameters.""" - pt_defaults = get_plate_wash_defaults(plate) - if dispense_volume is None: - dispense_volume = pt_defaults["dispense_volume"] - if dispense_z is None: - dispense_z = pt_defaults["dispense_z"] - if aspirate_z is None: - aspirate_z = pt_defaults["aspirate_z"] - if secondary_z is None: - secondary_z = pt_defaults["aspirate_z"] - if final_secondary_z is None: - final_secondary_z = pt_defaults["aspirate_z"] - return (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) - - @classmethod - def _validate_wash_core_params( - cls, - cycles: int, - buffer: Buffer, - dispense_volume: float, - dispense_flow_rate: int, - dispense_x: int, - dispense_y: int, - aspirate_travel_rate: int, - aspirate_x: int, - aspirate_y: int, - pre_dispense_flow_rate: int, - aspirate_delay_ms: int, - wash_format: Literal["Plate", "Sector"], - sector_mask: int, - ) -> None: - """Validate core wash dispense/aspirate parameters.""" - validate_cycles(cycles) - if dispense_volume <= 0: - raise ValueError(f"dispense_volume must be positive, got {dispense_volume}") - validate_buffer(buffer) - validate_flow_rate(dispense_flow_rate) - cls._validate_manifold_xy(dispense_x, dispense_y, "Wash dispense") - validate_travel_rate(aspirate_travel_rate) - cls._validate_manifold_xy(aspirate_x, aspirate_y, "Wash aspirate") - if wash_format not in ("Plate", "Sector"): - raise ValueError(f"wash_format must be 'Plate' or 'Sector', got '{wash_format}'") - if not 0 <= sector_mask <= 0xFFFF: - raise ValueError(f"sector_mask must be 0x0000-0xFFFF, got 0x{sector_mask:04X}") - validate_flow_rate(pre_dispense_flow_rate) - validate_delay_ms(aspirate_delay_ms) - - @classmethod - def _validate_wash_final_and_extras( - cls, - final_aspirate_x: int, - final_aspirate_y: int, - final_aspirate_delay_ms: int, - pre_dispense_volume: float, - vacuum_delay_volume: float, - soak_duration: int, - shake_duration: int, - shake_intensity: Intensity, - ) -> None: - """Validate final-aspirate, pre-dispense, soak/shake parameters.""" - cls._validate_manifold_xy(final_aspirate_x, final_aspirate_y, "Final aspirate") - validate_delay_ms(final_aspirate_delay_ms) - if pre_dispense_volume != 0 and not 25 <= pre_dispense_volume <= 3000: - raise ValueError( - f"Wash pre-dispense volume must be 0 (disabled) or 25-3000 uL, got {pre_dispense_volume}" - ) - if not 0 <= vacuum_delay_volume <= 3000: - raise ValueError(f"Wash vacuum delay volume must be 0-3000 uL, got {vacuum_delay_volume}") - if not 0 <= soak_duration <= 3599: - raise ValueError(f"Wash soak duration must be 0-3599 seconds, got {soak_duration}") - if not 0 <= shake_duration <= 3599: - raise ValueError(f"Wash shake duration must be 0-3599 seconds, got {shake_duration}") - validate_intensity(shake_intensity) - - @classmethod - def _validate_wash_secondary_aspirates( - cls, - secondary_aspirate: bool, - secondary_x: int, - secondary_y: int, - final_secondary_aspirate: bool, - final_secondary_x: int, - final_secondary_y: int, - ) -> None: - """Validate secondary and final-secondary aspirate offsets.""" - if secondary_aspirate: - cls._validate_manifold_xy(secondary_x, secondary_y, "Secondary") - if final_secondary_aspirate: - cls._validate_manifold_xy(final_secondary_x, final_secondary_y, "Final secondary") - - @staticmethod - def _validate_wash_optional_features( - bottom_wash: bool, - bottom_wash_volume: float, - bottom_wash_flow_rate: int, - pre_dispense_between_cycles_volume: float, - pre_dispense_between_cycles_flow_rate: int, - ) -> None: - """Validate bottom wash and mid-cycle pre-dispense.""" - if bottom_wash: - if not 25 <= bottom_wash_volume <= 3000: - raise ValueError(f"Bottom wash volume must be 25-3000 uL, got {bottom_wash_volume}") - validate_flow_rate(bottom_wash_flow_rate) - if pre_dispense_between_cycles_volume != 0: - if not 25 <= pre_dispense_between_cycles_volume <= 3000: - raise ValueError( - f"Pre-dispense between cycles volume must be 0 (disabled) or " - f"25-3000 uL, got {pre_dispense_between_cycles_volume}" - ) - validate_flow_rate(pre_dispense_between_cycles_flow_rate) - - def _validate_wash_params( - self, - plate: Plate, - cycles: int, - buffer: Buffer, - dispense_volume: float | None, - dispense_flow_rate: int, - dispense_x: int, - dispense_y: int, - dispense_z: int | None, - aspirate_travel_rate: int, - aspirate_z: int | None, - aspirate_x: int, - aspirate_y: int, - pre_dispense_flow_rate: int, - aspirate_delay_ms: int, - final_aspirate_x: int, - final_aspirate_y: int, - final_aspirate_delay_ms: int, - pre_dispense_volume: float, - vacuum_delay_volume: float, - soak_duration: int, - shake_duration: int, - shake_intensity: Intensity, - secondary_aspirate: bool, - secondary_z: int | None, - secondary_x: int, - secondary_y: int, - final_secondary_aspirate: bool, - final_secondary_z: int | None, - final_secondary_x: int, - final_secondary_y: int, - bottom_wash: bool, - bottom_wash_volume: float, - bottom_wash_flow_rate: int, - pre_dispense_between_cycles_volume: float, - pre_dispense_between_cycles_flow_rate: int, - wash_format: Literal["Plate", "Sector"], - sector_mask: int, - ) -> tuple[float, int, int, int, int]: - """Validate wash parameters and resolve plate-type defaults. - - Returns: - (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) - """ - ( - dispense_volume, - dispense_z, - aspirate_z, - secondary_z, - final_secondary_z, - ) = self._resolve_wash_defaults( - plate, - dispense_volume, - dispense_z, - aspirate_z, - secondary_z, - final_secondary_z, - ) - self._validate_wash_core_params( - cycles, - buffer, - dispense_volume, - dispense_flow_rate, - dispense_x, - dispense_y, - aspirate_travel_rate, - aspirate_x, - aspirate_y, - pre_dispense_flow_rate, - aspirate_delay_ms, - wash_format, - sector_mask, - ) - self._validate_wash_final_and_extras( - final_aspirate_x, - final_aspirate_y, - final_aspirate_delay_ms, - pre_dispense_volume, - vacuum_delay_volume, - soak_duration, - shake_duration, - shake_intensity, - ) - self._validate_wash_secondary_aspirates( - secondary_aspirate, - secondary_x, - secondary_y, - final_secondary_aspirate, - final_secondary_x, - final_secondary_y, - ) - self._validate_wash_optional_features( - bottom_wash, - bottom_wash_volume, - bottom_wash_flow_rate, - pre_dispense_between_cycles_volume, - pre_dispense_between_cycles_flow_rate, - ) - return (dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z) - - async def manifold_aspirate( - self, - plate: Plate, - vacuum_filtration: bool = False, - travel_rate: TravelRate = "3", - delay: float = 0.0, - vacuum_time: float = 30.0, - offset_x: int = 0, - offset_y: int = 0, - offset_z: int | None = None, - secondary_aspirate: bool = False, - secondary_x: int = 0, - secondary_y: int = 0, - secondary_z: int | None = None, - ) -> None: - """Aspirate liquid from all wells via the wash manifold. - - Two modes based on vacuum_filtration: - - Normal (vacuum_filtration=False): Uses travel_rate and delay. - - Vacuum filtration (vacuum_filtration=True): Uses vacuum_time. - Travel rate is ignored (greyed out in GUI). - - Args: - plate: PLR Plate resource. - vacuum_filtration: Enable vacuum filtration mode. - travel_rate: Head travel rate. Normal: "1"-"5". - Cell wash: "1 CW", "2 CW", "3 CW", "4 CW", "6 CW". - Ignored when vacuum_filtration=True. - delay: Post-aspirate delay in seconds (0-5). Only used when - vacuum_filtration=False. Wire resolution: 1 ms. - vacuum_time: Vacuum filtration time in seconds (5-999). Only used when - vacuum_filtration=True. - offset_x: X offset in steps (-60 to +60). - offset_y: Y offset in steps (-40 to +40). - offset_z: Z offset in steps (1-210). Default None (plate-type-aware: - 29 for 96-well, 22 for 384-well, etc.). - secondary_aspirate: Enable secondary aspirate (perform a second aspirate - at a different position). Not available for 1536-well plates. - secondary_x: Secondary aspirate X offset (-60 to +60). - secondary_y: Secondary aspirate Y offset (-40 to +40). - secondary_z: Secondary aspirate Z offset (1-210). Default None - (plate-type-aware, same as offset_z default). - - Raises: - ValueError: If parameters are invalid. - """ - # Convert PLR units (seconds) to wire units: seconds → milliseconds, seconds → integer seconds - delay_ms = round(delay * 1000) - vacuum_time_sec = round(vacuum_time) - - offset_z, secondary_z, time_value, rate_byte = self._validate_aspirate_params( - plate=plate, - vacuum_filtration=vacuum_filtration, - travel_rate=travel_rate, - delay_ms=delay_ms, - vacuum_time_sec=vacuum_time_sec, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - secondary_aspirate=secondary_aspirate, - secondary_x=secondary_x, - secondary_y=secondary_y, - secondary_z=secondary_z, - ) - - logger.info( - "Aspirating: vacuum=%s, travel_rate=%s, delay=%.3f s", - vacuum_filtration, - travel_rate, - delay, - ) - - data = self._build_aspirate_command( - plate=plate, - vacuum_filtration=vacuum_filtration, - time_value=time_value, - travel_rate_byte=rate_byte, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - secondary_mode=1 if secondary_aspirate else 0, - secondary_x=secondary_x, - secondary_y=secondary_y, - secondary_z=secondary_z, - ) - framed_command = build_framed_message(command=0xA5, data=data) - async with self.batch(plate): - await self._send_step_command(framed_command) - - async def manifold_dispense( - self, - plate: Plate, - volume: float, - buffer: Buffer = "A", - flow_rate: int = 7, - offset_x: int = 0, - offset_y: int = 0, - offset_z: int | None = None, - pre_dispense_volume: float = 0.0, - pre_dispense_flow_rate: int = 9, - vacuum_delay_volume: float = 0.0, - ) -> None: - """Dispense liquid to all wells via the wash manifold. - - Args: - plate: PLR Plate resource. - volume: Volume to dispense in uL/well. Range: 25-3000 uL (manifold-dependent: - 96-tube manifolds require ≥50, 192/128-tube manifolds allow ≥25). - buffer: Buffer valve selection (A, B, C, D). - flow_rate: Dispense flow rate (1-11, default 7). - Rates 1-2 are for cell wash mode only (96-tube dual-action manifold) - and require vacuum_delay_volume > 0. - Standard range is 3-11. - offset_x: X offset in steps (-60 to +60). - offset_y: Y offset in steps (-40 to +40). - offset_z: Z offset in steps (1-210). Default None (plate-type-aware: - 121 for 96-well, 120 for 384-well, etc.). - pre_dispense_volume: Pre-dispense volume in uL/tube (0 to disable, 25-3000 when enabled). - pre_dispense_flow_rate: Pre-dispense flow rate (3-11, default 9). - vacuum_delay_volume: Delay start of vacuum until volume dispensed in uL/well - (0 to disable, 0-3000 when enabled). Required for cell wash flow rates 1-2. - - Raises: - ValueError: If parameters are invalid. - """ - offset_z = self._validate_dispense_params( - plate=plate, - volume=volume, - buffer=buffer, - flow_rate=flow_rate, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - pre_dispense_volume=pre_dispense_volume, - pre_dispense_flow_rate=pre_dispense_flow_rate, - vacuum_delay_volume=vacuum_delay_volume, - ) - - logger.info( - "Dispensing %.1f uL from buffer %s, flow rate %d", - volume, - buffer, - flow_rate, - ) - - data = self._build_dispense_command( - plate=plate, - volume=volume, - buffer=buffer, - flow_rate=flow_rate, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - pre_dispense_volume=pre_dispense_volume, - pre_dispense_flow_rate=pre_dispense_flow_rate, - vacuum_delay_volume=vacuum_delay_volume, - ) - framed_command = build_framed_message(command=0xA6, data=data) - async with self.batch(plate): - await self._send_step_command(framed_command) - - async def manifold_wash( - self, - plate: Plate, - cycles: int = 3, - buffer: Buffer = "A", - dispense_volume: float | None = None, - dispense_flow_rate: int = 7, - dispense_x: int = 0, - dispense_y: int = 0, - dispense_z: int | None = None, - aspirate_travel_rate: int = 3, - aspirate_z: int | None = None, - pre_dispense_flow_rate: int = 9, - aspirate_delay: float = 0.0, - aspirate_x: int = 0, - aspirate_y: int = 0, - final_aspirate: bool = True, - final_aspirate_z: int | None = None, - final_aspirate_x: int = 0, - final_aspirate_y: int = 0, - final_aspirate_delay: float = 0.0, - pre_dispense_volume: float = 0.0, - vacuum_delay_volume: float = 0.0, - soak_duration: int = 0, - shake_duration: int = 0, - shake_intensity: Intensity = "Medium", - secondary_aspirate: bool = False, - secondary_z: int | None = None, - secondary_x: int = 0, - secondary_y: int = 0, - final_secondary_aspirate: bool = False, - final_secondary_z: int | None = None, - final_secondary_x: int = 0, - final_secondary_y: int = 0, - bottom_wash: bool = False, - bottom_wash_volume: float = 0.0, - bottom_wash_flow_rate: int = 5, - pre_dispense_between_cycles_volume: float = 0.0, - pre_dispense_between_cycles_flow_rate: int = 9, - wash_format: Literal["Plate", "Sector"] = "Plate", - sectors: list[int] | None = None, - move_home_first: bool = False, - ) -> None: - """Perform manifold wash cycles. - - Sends a 102-byte MANIFOLD_WASH (0xA4) command that performs repeated - dispense-aspirate cycles. The wire format contains two dispense sections, - two aspirate sections, and a final shake/soak section. - - The wash command supports 4 independent coordinate sets: - - Primary aspirate (aspirate_x/y/z): between-cycle aspirate position - - Primary secondary (secondary_x/y/z): second aspirate position per cycle - - Final aspirate (final_aspirate_x/y/z): aspirate after last cycle - - Final secondary (final_secondary_x/y/z): second position for final aspirate - - Args: - plate: PLR Plate resource. - cycles: Number of wash cycles (1-250). Default 3. - Encoded at header byte [6]. - buffer: Buffer valve selection (A, B, C, D). Default A. - dispense_volume: Volume to dispense per cycle in uL. Default None - (plate-type-aware: 300 for 96-well, 100 for others). - dispense_flow_rate: Flow rate for dispensing (1-9). Default 7. - dispense_x: Dispense X offset in steps (-60 to +60). Default 0. - dispense_y: Dispense Y offset in steps (-40 to +40). Default 0. - dispense_z: Z offset for dispense in 0.1mm units (1-210). Default None - (plate-type-aware: 121 for 96-well, 120 for 384-well, etc.). - aspirate_travel_rate: Travel rate for aspiration (1-9). Default 3. - aspirate_z: Z offset for aspirate in 0.1mm units (1-210). Default None - (plate-type-aware: 29 for 96-well, 22 for 384-well, etc.). - pre_dispense_flow_rate: Pre-dispense flow rate (3-11). Default 9. - Controls how fast the pre-dispense is delivered. - aspirate_delay: Post-aspirate delay in seconds (0-65.535). Default 0. - Wire resolution: 1 ms. - aspirate_x: Aspirate X offset in steps (-60 to +60). Default 0. - aspirate_y: Aspirate Y offset in steps (-40 to +40). Default 0. - final_aspirate: Enable final aspirate after last cycle. Default True. - Encoded in header config flags byte [2]. - final_aspirate_z: Z offset for final aspirate (1-210). Default None - (inherits from aspirate_z). Independent from primary aspirate Z. - final_aspirate_x: X offset for final aspirate (-60 to +60). Default 0. - final_aspirate_y: Y offset for final aspirate (-40 to +40). Default 0. - final_aspirate_delay: Post-aspirate delay for final aspirate in - seconds (0-65.535). Default 0. Wire resolution: 1 ms. - pre_dispense_volume: Pre-dispense volume in uL/tube (0 to disable, - 25-3000 when enabled). Default 0.0. - vacuum_delay_volume: Vacuum delay volume in uL/well (0 to disable, - 0-3000 when enabled). Cell wash operations only. Default 0.0. - soak_duration: Soak duration in seconds (0 to disable, 0-3599). Default 0. - shake_duration: Shake duration in seconds (0 to disable, 0-3599). Default 0. - shake_intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). - Default "Medium". - secondary_aspirate: Enable secondary aspirate for primary (between-cycle) - aspirate. Default False. - secondary_z: Z offset for secondary aspirate in 0.1mm units (1-210). - Default None (plate-type-aware, same as aspirate_z default). - secondary_x: Secondary aspirate X offset (-60 to +60). Default 0. - secondary_y: Secondary aspirate Y offset (-40 to +40). Default 0. - final_secondary_aspirate: Enable secondary aspirate for final aspirate. - Default False. - final_secondary_z: Z offset for final secondary aspirate (1-210). - Default None (plate-type-aware, same as aspirate_z default). - final_secondary_x: X offset for final secondary aspirate (-60 to +60). - Default 0. - final_secondary_y: Y offset for final secondary aspirate (-40 to +40). - Default 0. - bottom_wash: Enable bottom wash. Default False. Encoded in header[1]. - bottom_wash_volume: Bottom wash volume in uL (25-3000). Default 0.0. - bottom_wash_flow_rate: Bottom wash flow rate (3-11). Default 5. - pre_dispense_between_cycles_volume: Pre-dispense volume between wash - cycles in uL (0 to disable, 25-3000 when enabled). Default 0.0. - pre_dispense_between_cycles_flow_rate: Flow rate for pre-dispense between - cycles (3-11). Default 9. - wash_format: Wash format ("Plate" or "Sector"). Default "Plate". - Encoded at header[3]: Plate=0x00, Sector=0x01. - 384-well plates typically use "Sector" for quadrant-based washing. - sectors: List of quadrant numbers to wash (1-4). Default None (all 4). - Example: ``sectors=[1, 2]`` washes quadrants 1 and 2. - Only used when wash_format="Sector". - move_home_first: Move carrier to home position before shake/soak. - Default False. Same as in standalone shake interface. - Encoded at wire [87] (shake/soak section byte 0). - - Raises: - ValueError: If parameters are invalid. - """ - # Convert PLR units (seconds) to wire units (ms) - aspirate_delay_ms = round(aspirate_delay * 1000) - final_aspirate_delay_ms = round(final_aspirate_delay * 1000) - - # Convert sectors list to bitmask - if sectors is not None: - sector_mask = 0 - for q in sectors: - if not 1 <= q <= 4: - raise ValueError(f"Sector/quadrant must be 1-4, got {q}") - sector_mask |= 1 << (q - 1) - else: - sector_mask = 0x0F - - ( - dispense_volume, - dispense_z, - aspirate_z, - secondary_z, - final_secondary_z, - ) = self._validate_wash_params( - plate=plate, - cycles=cycles, - buffer=buffer, - dispense_volume=dispense_volume, - dispense_flow_rate=dispense_flow_rate, - dispense_x=dispense_x, - dispense_y=dispense_y, - dispense_z=dispense_z, - aspirate_travel_rate=aspirate_travel_rate, - aspirate_z=aspirate_z, - aspirate_x=aspirate_x, - aspirate_y=aspirate_y, - pre_dispense_flow_rate=pre_dispense_flow_rate, - aspirate_delay_ms=aspirate_delay_ms, - final_aspirate_x=final_aspirate_x, - final_aspirate_y=final_aspirate_y, - final_aspirate_delay_ms=final_aspirate_delay_ms, - pre_dispense_volume=pre_dispense_volume, - vacuum_delay_volume=vacuum_delay_volume, - soak_duration=soak_duration, - shake_duration=shake_duration, - shake_intensity=shake_intensity, - secondary_aspirate=secondary_aspirate, - secondary_z=secondary_z, - secondary_x=secondary_x, - secondary_y=secondary_y, - final_secondary_aspirate=final_secondary_aspirate, - final_secondary_z=final_secondary_z, - final_secondary_x=final_secondary_x, - final_secondary_y=final_secondary_y, - bottom_wash=bottom_wash, - bottom_wash_volume=bottom_wash_volume, - bottom_wash_flow_rate=bottom_wash_flow_rate, - pre_dispense_between_cycles_volume=pre_dispense_between_cycles_volume, - pre_dispense_between_cycles_flow_rate=pre_dispense_between_cycles_flow_rate, - wash_format=wash_format, - sector_mask=sector_mask, - ) - - logger.info( - "Manifold wash: %d cycles, %.1f uL, buffer %s, flow %d, " - "disp_xy=(%d,%d), z_disp=%d, z_asp=%d, pre_disp_flow=%d, " - "asp_delay=%.3f s, asp_xy=(%d,%d), final_asp=%s, " - "pre_disp=%.1f, vac_delay=%.1f, soak=%d, shake=%d/%s, " - "sec_asp=%s, sec_z=%d, sec_xy=(%d,%d), " - "btm_wash=%s/%.1f/%d, midcyc=%.1f/%d", - cycles, - dispense_volume, - buffer, - dispense_flow_rate, - dispense_x, - dispense_y, - dispense_z, - aspirate_z, - pre_dispense_flow_rate, - aspirate_delay, - aspirate_x, - aspirate_y, - final_aspirate, - pre_dispense_volume, - vacuum_delay_volume, - soak_duration, - shake_duration, - shake_intensity, - secondary_aspirate, - secondary_z, - secondary_x, - secondary_y, - bottom_wash, - bottom_wash_volume, - bottom_wash_flow_rate, - pre_dispense_between_cycles_volume, - pre_dispense_between_cycles_flow_rate, - ) - - data = self._build_wash_composite_command( - plate=plate, - cycles=cycles, - buffer=buffer, - dispense_volume=dispense_volume, - dispense_flow_rate=dispense_flow_rate, - dispense_x=dispense_x, - dispense_y=dispense_y, - dispense_z=dispense_z, - aspirate_travel_rate=aspirate_travel_rate, - aspirate_z=aspirate_z, - pre_dispense_flow_rate=pre_dispense_flow_rate, - aspirate_delay_ms=aspirate_delay_ms, - aspirate_x=aspirate_x, - aspirate_y=aspirate_y, - final_aspirate=final_aspirate, - final_aspirate_z=final_aspirate_z, - final_aspirate_x=final_aspirate_x, - final_aspirate_y=final_aspirate_y, - final_aspirate_delay_ms=final_aspirate_delay_ms, - pre_dispense_volume=pre_dispense_volume, - vacuum_delay_volume=vacuum_delay_volume, - soak_duration=soak_duration, - shake_duration=shake_duration, - shake_intensity=shake_intensity, - secondary_aspirate=secondary_aspirate, - secondary_z=secondary_z, - secondary_x=secondary_x, - secondary_y=secondary_y, - final_secondary_aspirate=final_secondary_aspirate, - final_secondary_z=final_secondary_z, - final_secondary_x=final_secondary_x, - final_secondary_y=final_secondary_y, - bottom_wash=bottom_wash, - bottom_wash_volume=bottom_wash_volume, - bottom_wash_flow_rate=bottom_wash_flow_rate, - pre_dispense_between_cycles_volume=pre_dispense_between_cycles_volume, - pre_dispense_between_cycles_flow_rate=pre_dispense_between_cycles_flow_rate, - wash_format=wash_format, - sector_mask=sector_mask, - move_home_first=move_home_first, - ) - - framed_command = build_framed_message(command=0xA4, data=data) - # Dynamic timeout: base per cycle + shake + soak + buffer - # Each cycle takes ~10-30s depending on volume/flow/plate type. - # Use 60s per cycle as generous safety margin to avoid false timeouts. - wash_timeout = (cycles * 60) + shake_duration + soak_duration + 120 - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=wash_timeout) - - async def manifold_prime( - self, - plate: Plate, - volume: float, - buffer: Buffer = "A", - flow_rate: int = 9, - low_flow_volume: float = 5000.0, - submerge_duration: float = 0.0, - ) -> None: - """Prime the manifold fluid lines. - - Fills the wash manifold tubing with liquid from the specified buffer. - This is typically done at the start of a protocol to ensure the lines - are filled and ready for dispensing. - - Args: - plate: PLR Plate resource. - volume: Prime volume in uL. Range: 5000-999000 uL. - Wire resolution: 1000 uL (1 mL). - buffer: Buffer valve selection (A, B, C, D). - flow_rate: Flow rate (3-11, default 9). - low_flow_volume: Low flow path volume in uL (5000-999000, default 5000). - Set to 0 to disable. Wire resolution: 1000 uL (1 mL). - submerge_duration: Submerge duration in seconds (0 to disable, 60-86340 when - enabled). Wire resolution: 60 s (1 minute). - - Raises: - ValueError: If parameters are invalid. - """ - # Validate in PLR units - if not 5000 <= volume <= 999000: - raise ValueError(f"Washer prime volume must be 5000-999000 uL, got {volume}") - validate_buffer(buffer) - if not 3 <= flow_rate <= 11: - raise ValueError(f"Washer prime flow rate must be 3-11, got {flow_rate}") - if low_flow_volume != 0 and not 5000 <= low_flow_volume <= 999000: - raise ValueError( - f"Low flow path volume must be 0 (disabled) or 5000-999000 uL, got {low_flow_volume}" - ) - if submerge_duration != 0 and not 60 <= submerge_duration <= 86340: - raise ValueError( - f"Submerge duration must be 0 (disabled) or 60-86340 seconds, got {submerge_duration}" - ) - if submerge_duration % 60 != 0: - raise ValueError( - f"Submerge duration must be a multiple of 60 seconds (device resolution is 1 minute), " - f"got {submerge_duration}" - ) - - # Convert to wire units: uL → mL, seconds → minutes - volume_ml = round(volume / 1000) - low_flow_volume_ml = round(low_flow_volume / 1000) - submerge_duration_min = round(submerge_duration / 60) - - low_flow_enabled = low_flow_volume > 0 - submerge_enabled = submerge_duration > 0 - - logger.info( - "Manifold prime: %.1f uL from buffer %s, flow rate %d, low_flow=%s/%.0f uL, " - "submerge=%s/%.0f s", - volume, - buffer, - flow_rate, - "enabled" if low_flow_enabled else "disabled", - low_flow_volume, - "enabled" if submerge_enabled else "disabled", - submerge_duration, - ) - - data = self._build_manifold_prime_command( - plate=plate, - buffer=buffer, - volume_ml=volume_ml, - flow_rate=flow_rate, - low_flow_volume_ml=low_flow_volume_ml, - low_flow_enabled=low_flow_enabled, - submerge_enabled=submerge_enabled, - submerge_duration_min=submerge_duration_min, - ) - framed_command = build_framed_message(command=0xA7, data=data) - # Timeout: base time for priming + submerge duration + buffer - prime_timeout = self.timeout + submerge_duration + 30 - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=prime_timeout) - - async def manifold_auto_clean( - self, - plate: Plate, - buffer: Buffer = "A", - duration: float = 60.0, - ) -> None: - """Run a manifold auto-clean cycle. - - Args: - plate: PLR Plate resource. - buffer: Buffer valve to use (A, B, C, or D). - duration: Cleaning duration in seconds (60-14340, i.e. up to 3h59m). - Wire resolution: 60 s (1 minute). - - Raises: - ValueError: If parameters are invalid. - """ - validate_buffer(buffer) - if not 60 <= duration <= 14340: - raise ValueError(f"AutoClean duration must be 60-14340 seconds, got {duration}") - if duration % 60 != 0: - raise ValueError( - f"AutoClean duration must be a multiple of 60 seconds (device resolution is 1 minute), " - f"got {duration}" - ) - - # Convert to wire units: seconds → minutes - duration_min = round(duration / 60) - - logger.info("Auto-clean: buffer %s, duration %.0f s", buffer, duration) - - data = self._build_auto_clean_command( - plate=plate, - buffer=buffer, - duration_min=duration_min, - ) - framed_command = build_framed_message(command=0xA8, data=data) - auto_clean_timeout = max(120.0, duration + 30.0) - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=auto_clean_timeout) - - # ========================================================================= - # COMMAND BUILDERS - # ========================================================================= - - def _build_wash_composite_command( - self, - plate: Plate, - cycles: int = 3, - buffer: Buffer = "A", - dispense_volume: float | None = None, - dispense_flow_rate: int = 7, - dispense_x: int = 0, - dispense_y: int = 0, - dispense_z: int | None = None, - aspirate_travel_rate: int = 3, - aspirate_z: int | None = None, - pre_dispense_flow_rate: int = 9, - aspirate_delay_ms: int = 0, - aspirate_x: int = 0, - aspirate_y: int = 0, - final_aspirate: bool = True, - final_aspirate_z: int | None = None, - final_aspirate_x: int = 0, - final_aspirate_y: int = 0, - final_aspirate_delay_ms: int = 0, - pre_dispense_volume: float = 0.0, - vacuum_delay_volume: float = 0.0, - soak_duration: int = 0, - shake_duration: int = 0, - shake_intensity: Intensity = "Medium", - secondary_aspirate: bool = False, - secondary_z: int | None = None, - secondary_x: int = 0, - secondary_y: int = 0, - final_secondary_aspirate: bool = False, - final_secondary_z: int | None = None, - final_secondary_x: int = 0, - final_secondary_y: int = 0, - bottom_wash: bool = False, - bottom_wash_volume: float = 0.0, - bottom_wash_flow_rate: int = 5, - pre_dispense_between_cycles_volume: float = 0.0, - pre_dispense_between_cycles_flow_rate: int = 9, - wash_format: Literal["Plate", "Sector"] = "Plate", - sector_mask: int = 0x0F, - move_home_first: bool = False, - ) -> bytes: - """Build 102-byte MANIFOLD_WASH (0xA4) command payload. - - Structure: header(7) + dispense1(22) + final_aspirate(20) + primary_aspirate(19) - + dispense2(19) + shake_soak(15) = 102 bytes. - - Header [0-6]: - [0] plate_type (plate_type.value) - [1] bottom_wash enable - [2] config flags -- final_aspirate - [3] wash_format -- 0=Plate, 1=Sector - [4-5] sector_mask as 16-bit LE - [6] wash cycles count - - Four coordinate sets for aspirate positions: - - Primary: aspirate_x/y/z (between-cycle aspirate, wire [49-67]) - - Primary secondary: secondary_x/y/z (wire [55-61]) - - Final: final_aspirate_x/y/z (post-cycle aspirate, wire [29-48]) - - Final secondary: final_secondary_x/y/z (wire [37-41]) - - Returns: - 102-byte command payload. - """ - # Resolve plate-type defaults - ( - dispense_volume, - dispense_z, - aspirate_z, - secondary_z, - final_secondary_z, - ) = self._resolve_wash_defaults( - plate, dispense_volume, dispense_z, aspirate_z, secondary_z, final_secondary_z - ) - - # Derived values - buffer_char = ord(buffer.upper()) - disp_vol = int(dispense_volume) - final_asp_z = final_aspirate_z if final_aspirate_z is not None else aspirate_z - pre_disp = int(pre_dispense_volume) if pre_dispense_volume > 0 else 0 - vac_delay = int(vacuum_delay_volume) if vacuum_delay_volume > 0 else 0 - intensity_byte = INTENSITY_TO_BYTE.get(shake_intensity, 0x03) if shake_duration > 0 else 0x00 - - # Secondary aspirate offsets (0 when disabled) - sec_x = secondary_x if secondary_aspirate else 0 - sec_y = secondary_y if secondary_aspirate else 0 - final_sec_x = final_secondary_x if final_secondary_aspirate else 0 - final_sec_y = final_secondary_y if final_secondary_aspirate else 0 - final_sec_z = final_secondary_z if final_secondary_aspirate else final_asp_z - - # Bottom wash: Dispense1 gets bottom wash params when enabled, else mirrors main - bw_vol = int(bottom_wash_volume) if bottom_wash else disp_vol - bw_flow = bottom_wash_flow_rate if bottom_wash else dispense_flow_rate - - # Pre-dispense between cycles: override or fall back to main pre-dispense - if pre_dispense_between_cycles_volume > 0: - midcyc_vol = int(pre_dispense_between_cycles_volume) - midcyc_flow = pre_dispense_between_cycles_flow_rate - else: - midcyc_vol = pre_disp - midcyc_flow = pre_dispense_flow_rate - - w = Writer() - - # --- Header [0-6] (7 bytes) --- - w.u8(plate_to_wire_byte(plate)) # [0] Plate type - w.u8(0x01 if bottom_wash else 0x00) # [1] Bottom wash enable - w.u8(0x01 if final_aspirate else 0x00) # [2] Config flags - w.u8({"Plate": 0x00, "Sector": 0x01}[wash_format]) # [3] Wash format - w.u16(sector_mask) # [4-5] Sector mask (LE) - w.u8(cycles) # [6] Wash cycles - - # --- Dispense section 1 [7-28] (22 bytes) — bottom wash or mirror of main --- - w.u8(buffer_char) # [7] Buffer (ASCII) - w.u16(bw_vol) # [8-9] Volume (LE) - w.u8(bw_flow) # [10] Flow rate - w.i8(dispense_x) # [11] Offset X - w.i8(dispense_y) # [12] Offset Y - w.u16(dispense_z) # [13-14] Dispense Z (LE) - w.u16(pre_disp) # [15-16] Pre-dispense vol (LE) - w.u8(pre_dispense_flow_rate) # [17] Pre-dispense flow rate - w.u16(vac_delay) # [18-19] Vacuum delay vol (LE) - w.raw_bytes(b"\x00" * 7) # [20-26] Padding - w.u16(final_aspirate_delay_ms) # [27-28] Final asp delay (LE) - - # --- Final aspirate section [29-48] (20 bytes) --- - w.u8(aspirate_travel_rate) # [29] Travel rate - w.u16(0x0000) # [30-31] Delay (always 0 here) - w.u16(final_asp_z) # [32-33] Final aspirate Z (LE) - w.u8(0x01 if final_secondary_aspirate else 0x00) # [34] Final secondary mode - w.i8(final_aspirate_x) # [35] Final aspirate X - w.i8(final_aspirate_y) # [36] Final aspirate Y - w.u16(final_sec_z) # [37-38] Final secondary Z (LE) - w.u8(0x00) # [39] Reserved - w.i8(final_sec_x) # [40] Final secondary X - w.i8(final_sec_y) # [41] Final secondary Y - w.raw_bytes(b"\x00" * 5) # [42-46] Reserved - w.u8(0x00) # [47] vac_filt (always 0 in wash) - # aspirate_delay_ms split: low byte here, high byte starts next section - w.u8(aspirate_delay_ms & 0xFF) # [48] asp delay low - - # --- Primary aspirate section [49-67] (19 bytes) --- - w.u8((aspirate_delay_ms >> 8) & 0xFF) # [49] asp delay high - w.u8(aspirate_travel_rate) # [50] Travel rate - w.i8(aspirate_x) # [51] Aspirate X - w.i8(aspirate_y) # [52] Aspirate Y - w.u16(aspirate_z) # [53-54] Aspirate Z (LE) - w.u8(0x01 if secondary_aspirate else 0x00) # [55] Secondary mode - w.i8(sec_x) # [56] Secondary X - w.i8(sec_y) # [57] Secondary Y - w.u16(secondary_z) # [58-59] Secondary Z (LE) - w.raw_bytes(b"\x00" * 8) # [60-67] Reserved - - # --- Dispense section 2 [68-86] (19 bytes) — main dispense --- - w.u8(buffer_char) # [68] Buffer (ASCII) - w.u16(disp_vol) # [69-70] Volume (LE) - w.u8(dispense_flow_rate) # [71] Flow rate - w.i8(dispense_x) # [72] Offset X - w.i8(dispense_y) # [73] Offset Y - w.u16(dispense_z) # [74-75] Dispense Z (LE) - w.u16(midcyc_vol) # [76-77] Mid-cycle vol (LE) - w.u8(midcyc_flow) # [78] Mid-cycle flow rate - w.u16(vac_delay) # [79-80] Vacuum delay vol (LE) - w.raw_bytes(b"\x00" * 6) # [81-86] Padding - - # --- Shake/soak section [87-101] (15 bytes) --- - w.u8(0x01 if move_home_first else 0x00) # [87] move_home_first - w.u16(shake_duration) # [88-89] Shake duration (LE) - w.u8(intensity_byte if shake_duration > 0 else 0x03) # [90] Intensity - w.u8(0x00) # [91] Shake type (always 0) - w.u16(soak_duration) # [92-93] Soak duration (LE) - w.raw_bytes(b"\x00" * 4) # [94-97] Padding - w.raw_bytes(b"\x00" * 4) # [98-101] Trailing padding - - data = w.finish() - assert len(data) == 102, f"Wash command should be 102 bytes, got {len(data)}" - - logger.debug("Wash command data (%d bytes): %s", len(data), data.hex()) - return data - - def _build_aspirate_command( - self, - plate: Plate, - vacuum_filtration: bool = False, - time_value: int = 0, - travel_rate_byte: int = 3, - offset_x: int = 0, - offset_y: int = 0, - offset_z: int = 30, - secondary_mode: int = 0, - secondary_x: int = 0, - secondary_y: int = 0, - secondary_z: int = 30, - ) -> bytes: - """Build aspirate command bytes. - - Wire format (22 bytes): - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1] vacuum_filtration: 0 or 1 - [2-3] time_value: ushort LE. delay_ms when normal, vacuum_time_sec when vacuum. - [4] travel_rate: byte from lookup table - [5] x_offset: signed byte - [6] y_offset: signed byte - [7-8] z_offset: short LE - [9] secondary_mode: byte (0=None, 1=enabled) - [10] secondary_x: signed byte - [11] secondary_y: signed byte - [12-13] secondary_z: short LE - [14-15] reserved: 0x0000 - [16-17] unknown: 0xFF0F (possibly column mask?) - [18-21] padding: 4 bytes 0x00 - - Args: - vacuum_filtration: Enable vacuum filtration. - time_value: Delay in ms (normal mode) or time in seconds (vacuum mode). - travel_rate_byte: Pre-encoded travel rate byte value. - offset_x: X offset (signed byte). - offset_y: Y offset (signed byte). - offset_z: Z offset (unsigned short). - secondary_mode: Secondary aspirate mode byte (0=None, 1=enabled). - secondary_x: Secondary X offset (signed byte). - secondary_y: Secondary Y offset (signed byte). - secondary_z: Secondary Z offset (unsigned short). - - Returns: - Command bytes (22 bytes). - """ - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(1 if vacuum_filtration else 0) # [1] Vacuum filtration - .u16(time_value) # [2-3] Time/delay (LE) - .u8(travel_rate_byte & 0xFF) # [4] Travel rate - .i8(offset_x) # [5] X offset - .i8(offset_y) # [6] Y offset - .u16(offset_z) # [7-8] Z offset (LE) - .u8(secondary_mode & 0xFF) # [9] Secondary mode - .i8(secondary_x) # [10] Secondary X - .i8(secondary_y) # [11] Secondary Y - .u16(secondary_z) # [12-13] Secondary Z (LE) - .raw_bytes(b'\x00' * 2) # [14-15] Reserved - .raw_bytes(b'\xff\x0f') # [16-17] Unknown, possibly column mask - .raw_bytes(b'\x00' * 4) # [18-21] Padding - .finish() - ) # fmt: skip - - def _build_dispense_command( - self, - plate: Plate, - volume: float, - buffer: Buffer, - flow_rate: int, - offset_x: int = 0, - offset_y: int = 0, - offset_z: int = 121, - pre_dispense_volume: float = 0.0, - pre_dispense_flow_rate: int = 9, - vacuum_delay_volume: float = 0.0, - ) -> bytes: - """Build manifold dispense command bytes. - - Protocol format for manifold dispense: - Wire format: 20 bytes (19 + plate type prefix) - - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) - [2-3] Volume: 2 bytes, LE, in uL (25-3000) - [4] Flow rate: 1-11 (1-2 = cell wash, requires vacuum delay) - [5] Offset X: signed byte (-60..60) - [6] Offset Y: signed byte (-40..40) - [7-8] Offset Z: 2 bytes, LE (1-210) - [9-10] Pre-dispense volume: 2 bytes, LE (0 if disabled, 25-3000 when enabled) - [11] Pre-dispense flow rate: 3-11 - [12-13] Vacuum delay volume: 2 bytes, LE (0 if disabled, 0-3000) - [14-19] Padding: 6 bytes (0x00) - - Note: Pre-dispense is enabled when pre_dispense_volume > 0. - Vacuum delay is enabled when vacuum_delay_volume > 0. - - Args: - volume: Dispense volume in uL. - buffer: Buffer valve (A, B, C, D). - flow_rate: Flow rate (1-11; 1-2 = cell wash, requires vacuum delay). - offset_x: X offset (signed, steps, -60..60). - offset_y: Y offset (signed, steps, -40..40). - offset_z: Z offset (steps, 1-210). - pre_dispense_volume: Pre-dispense volume in uL (0 to disable). - pre_dispense_flow_rate: Pre-dispense flow rate (3-11). - vacuum_delay_volume: Vacuum delay volume in uL (0 to disable). - - Returns: - Command bytes (20 bytes). - """ - pre_disp_vol = int(pre_dispense_volume) if pre_dispense_volume > 0 else 0 - vac_delay = int(vacuum_delay_volume) if vacuum_delay_volume > 0 else 0 - - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(ord(buffer.upper())) # [1] Buffer (ASCII) - .u16(int(volume)) # [2-3] Volume (LE) - .u8(flow_rate) # [4] Flow rate - .i8(offset_x) # [5] X offset - .i8(offset_y) # [6] Y offset - .u16(offset_z) # [7-8] Z offset (LE) - .u16(pre_disp_vol) # [9-10] Pre-dispense volume (LE) - .u8(pre_dispense_flow_rate) # [11] Pre-dispense flow rate - .u16(vac_delay) # [12-13] Vacuum delay volume (LE) - .raw_bytes(b'\x00' * 6) # [14-19] Padding - .finish() - ) # fmt: skip - - def _build_manifold_prime_command( - self, - plate: Plate, - buffer: Buffer, - volume_ml: float, - flow_rate: int = 9, - low_flow_volume_ml: int = 5, - low_flow_enabled: bool = True, - submerge_enabled: bool = False, - submerge_duration_min: int = 0, - ) -> bytes: - """Build manifold prime command bytes. - - Protocol format for manifold prime (13 bytes): - - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) - [2-3] Volume: 2 bytes, little-endian, in mL - [4] Flow rate: 3-11 - [5-6] Low flow volume: 2 bytes, little-endian (in mL, 0 if disabled) - [7-8] Submerge duration: 2 bytes, little-endian (in minutes, 0 if disabled) - HH:MM encoded as total minutes: hours*60+minutes - [9-12] Padding zeros: 4 bytes - - Args: - buffer: Buffer valve (A, B, C, D). - volume_ml: Prime volume in mL. - flow_rate: Flow rate (3-11, default 9). - low_flow_volume_ml: Low flow volume in mL (default 5). - low_flow_enabled: Enable low flow path (default True). - submerge_enabled: Enable submerge tips after prime (default False). - submerge_duration_min: Submerge duration in minutes (default 0). - - Returns: - Command bytes (13 bytes). - """ - lf_vol = low_flow_volume_ml if (low_flow_enabled and low_flow_volume_ml > 0) else 0 - sub_dur = submerge_duration_min if submerge_enabled else 0 - - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(ord(buffer.upper())) # [1] Buffer (ASCII) - .u16(int(volume_ml)) # [2-3] Volume (LE, mL) - .u8(flow_rate) # [4] Flow rate - .u16(lf_vol) # [5-6] Low flow volume (LE, mL) - .u16(sub_dur) # [7-8] Submerge duration (LE, minutes) - .raw_bytes(b'\x00' * 4) # [9-12] Padding - .finish() - ) # fmt: skip - - def _build_auto_clean_command( - self, - plate: Plate, - buffer: Buffer, - duration_min: int = 1, - ) -> bytes: - """Build auto-clean command bytes. - - Protocol format for auto-clean (8 bytes): - - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1] Buffer letter: A=0x41, B=0x42, C=0x43, D=0x44 (ASCII char) - [2-3] Duration: 2 bytes, little-endian (in minutes) - [4-7] Padding zeros: 4 bytes - - Args: - buffer: Buffer valve (A, B, C, D). - duration_min: Cleaning duration in minutes (1-239). - - Returns: - Command bytes (8 bytes). - """ - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(ord(buffer.upper())) # [1] Buffer (ASCII) - .u16(int(duration_min)) # [2-3] Duration (LE, minutes) - .raw_bytes(b'\x00' * 4) # [4-7] Padding - .finish() - ) # fmt: skip diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_peristaltic.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_peristaltic.py index 6f06d4eb883..6b6be0143fd 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_peristaltic.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_peristaltic.py @@ -1,464 +1,4 @@ -"""EL406 peristaltic pump step methods. +"""EL406 peristaltic pump step methods — legacy wrapper. -Provides peristaltic_prime, peristaltic_dispense, and peristaltic_purge operations -plus their corresponding command builders. +Implementation has moved to pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend. """ - -from __future__ import annotations - -import logging -from typing import Literal - -from pylabrobot.io.binary import Writer -from pylabrobot.resources import Plate - -from ..helpers import ( - plate_default_z, - plate_max_columns, - plate_max_row_groups, - plate_to_wire_byte, - plate_well_count, -) -from ..protocol import build_framed_message, columns_to_column_mask, encode_column_mask -from ._base import EL406StepsBaseMixin - -logger = logging.getLogger(__name__) - -PeristalticFlowRate = Literal["Low", "Medium", "High"] -Cassette = Literal["Any", "1uL", "5uL", "10uL"] - -PERISTALTIC_FLOW_RATE_MAP: dict[str, int] = {"Low": 0, "Medium": 1, "High": 2} - - -def cassette_to_byte(cassette: Cassette) -> int: - mapping = {"ANY": 0, "1UL": 1, "5UL": 2, "10UL": 3} - key = cassette.upper() - if key not in mapping: - raise ValueError(f"Invalid cassette '{cassette}'. Must be one of: Any, 1uL, 5uL, 10uL") - return mapping[key] - - -def encode_quadrant_mask_inverted( - rows: list[int] | None, - num_row_groups: int = 4, -) -> int: - """Encode row/quadrant selection as inverted bitmask. - - The protocol uses INVERTED encoding for the quadrant/row mask byte: - 0 = selected, 1 = deselected. This is the opposite of the well mask. - - Args: - rows: List of row numbers (1 to num_row_groups) to select, or None for all. - If None, returns 0x00 (all selected in inverted encoding). - num_row_groups: Number of valid row groups for this plate type (1, 2, or 4). - - Returns: - Single byte with inverted bit encoding (only lower num_row_groups bits used). - - Raises: - ValueError: If any row number is out of range. - """ - if rows is None: - return 0x00 - - max_mask = (1 << num_row_groups) - 1 - mask = max_mask - for row in rows: - if row < 1 or row > num_row_groups: - raise ValueError(f"Row number {row} out of range. Must be 1-{num_row_groups}.") - mask &= ~(1 << (row - 1)) - - return mask & 0xFF - - -def validate_peristaltic_flow_rate(flow_rate: PeristalticFlowRate) -> None: - if flow_rate not in PERISTALTIC_FLOW_RATE_MAP: - raise ValueError( - f"flow_rate must be one of {sorted(PERISTALTIC_FLOW_RATE_MAP)}, got {flow_rate!r}" - ) - - -class EL406PeristalticStepsMixin(EL406StepsBaseMixin): - """Mixin for peristaltic pump step operations.""" - - def _validate_peristaltic_well_selection( - self, - plate: Plate, - columns: list[int] | None, - rows: list[int] | None, - ) -> list[int] | None: - """Validate column/row selection and return column mask.""" - max_cols = plate_max_columns(plate) - if columns is not None: - for col in columns: - if col < 1 or col > max_cols: - raise ValueError(f"Column {col} out of range for plate type (1-{max_cols}).") - - max_rows = plate_max_row_groups(plate) - if rows is not None: - for row in rows: - if row < 1 or row > max_rows: - raise ValueError(f"Row {row} out of range for plate type (1-{max_rows}).") - - return columns_to_column_mask(columns, plate_wells=plate_well_count(plate)) - - def _validate_peristaltic_dispense_params( - self, - plate: Plate, - volume: float, - flow_rate: PeristalticFlowRate, - offset_x: int, - offset_y: int, - offset_z: int | None, - pre_dispense_volume: float, - columns: list[int] | None, - rows: list[int] | None, - ) -> tuple[int, int, list[int] | None]: - """Validate peristaltic dispense parameters and resolve defaults. - - Returns: - (offset_z, flow_rate_enum, column_mask) - """ - if not 1 <= volume <= 3000: - raise ValueError(f"Peri-pump dispense volume must be 1-3000 uL, got {volume}") - validate_peristaltic_flow_rate(flow_rate) - if not -125 <= offset_x <= 125: - raise ValueError(f"Peri-pump dispense X-axis offset must be -125..125, got {offset_x}") - if not -40 <= offset_y <= 40: - raise ValueError(f"Peri-pump dispense Y-axis offset must be -40..40, got {offset_y}") - - if offset_z is None: - offset_z = plate_default_z(plate) - if not 1 <= offset_z <= 1500: - raise ValueError(f"Peri-pump dispense Z-axis offset must be 1..1500, got {offset_z}") - - if pre_dispense_volume < 0: - raise ValueError(f"pre_dispense_volume must be non-negative, got {pre_dispense_volume}") - - column_mask = self._validate_peristaltic_well_selection(plate, columns, rows) - - return (offset_z, PERISTALTIC_FLOW_RATE_MAP[flow_rate], column_mask) - - async def peristaltic_prime( - self, - plate: Plate, - volume: float | None = None, - duration: int | None = None, - flow_rate: PeristalticFlowRate = "High", - cassette: Cassette = "Any", - ) -> None: - """Prime the peristaltic fluid lines. - - Specify either ``volume`` (uL/tube) or ``duration`` (seconds), not both. - If neither is given, defaults to volume mode with 1000 uL. - - Note: Peristaltic prime has no buffer selection. - Use ``manifold_prime()`` for buffer-specific priming. - - Args: - plate: PLR Plate resource. - volume: Volume to prime in microliters. - duration: Fixed duration in seconds (alternative to volume). - flow_rate: Flow rate ("Low", "Medium", or "High"). - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). - - Raises: - ValueError: If both volume and duration are specified, or if parameters are invalid. - """ - if volume is not None and duration is not None: - raise ValueError("Specify either volume or duration, not both.") - - if duration is not None: - if not 1 <= duration <= 300: - raise ValueError("duration must be 1-300 seconds") - prime_volume = 0.0 - prime_duration = duration - else: - if volume is None: - volume = 1000.0 - if not 1 <= volume <= 3000: - raise ValueError("volume must be 1-3000 uL (GUI limit)") - prime_volume = volume - prime_duration = 0 - - validate_peristaltic_flow_rate(flow_rate) - - logger.info( - "Peristaltic prime: %.1f uL, flow rate %s, cassette %s", prime_volume, flow_rate, cassette - ) - - data = self._build_peristaltic_prime_command( - plate=plate, - volume=prime_volume, - duration=prime_duration, - flow_rate=PERISTALTIC_FLOW_RATE_MAP[flow_rate], - reverse=True, - cassette=cassette, - pump=1, - ) - framed_command = build_framed_message(command=0x90, data=data) - # Timeout: duration (if specified) + buffer for volume-based priming - prime_timeout = self.timeout + prime_duration + 30 - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=prime_timeout) - - async def peristaltic_dispense( - self, - plate: Plate, - volume: float, - flow_rate: PeristalticFlowRate = "High", - offset_x: int = 0, - offset_y: int = 0, - offset_z: int | None = None, - pre_dispense_volume: float = 10.0, - num_pre_dispenses: int = 2, - cassette: Cassette = "Any", - columns: list[int] | None = None, - rows: list[int] | None = None, - ) -> None: - """Dispense liquid using the peristaltic pump. - - Args: - plate: PLR Plate resource. - volume: Dispense volume in microliters (1-3000). - flow_rate: Flow rate ("Low", "Medium", or "High"). - offset_x: X offset in 0.1mm units (-125 to 125). - offset_y: Y offset in 0.1mm units (-40 to 40). - offset_z: Z offset in 0.1mm units (1-1500). Default depends on plate type: - 336 for 96/384-well, 254 for 1536-well. - pre_dispense_volume: Pre-dispense volume in uL (0 to disable). - num_pre_dispenses: Number of pre-dispenses (default 2). - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). - columns: List of 1-indexed column numbers to dispense to, or None for all. - For 96-well: 1-12, for 384-well: 1-24, for 1536-well: 1-48. - rows: List of 1-indexed row group numbers, or None for all. - For 96-well: only 1 (no selection). For 384-well: 1-2. For 1536-well: 1-4. - - Raises: - ValueError: If parameters are invalid. - """ - offset_z, flow_rate_enum, column_mask = self._validate_peristaltic_dispense_params( - plate=plate, - volume=volume, - flow_rate=flow_rate, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - pre_dispense_volume=pre_dispense_volume, - columns=columns, - rows=rows, - ) - - logger.info( - "Peristaltic dispense: %.1f uL, flow rate %s, cassette %s", - volume, - flow_rate, - cassette, - ) - - data = self._build_peristaltic_dispense_command( - plate=plate, - volume=volume, - flow_rate=flow_rate_enum, - cassette=cassette, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - pre_dispense_volume=pre_dispense_volume, - num_pre_dispenses=num_pre_dispenses, - column_mask=column_mask, - rows=rows, - pump=1, - ) - framed_command = build_framed_message(command=0x8F, data=data) - async with self.batch(plate): - await self._send_step_command(framed_command) - - async def peristaltic_purge( - self, - plate: Plate, - volume: float | None = None, - duration: int | None = None, - flow_rate: PeristalticFlowRate = "High", - cassette: Cassette = "Any", - ) -> None: - """Purge the fluid lines using the peristaltic pump. - - Specify either ``volume`` (uL/tube) or ``duration`` (seconds), not both. - - PERISTALTIC_PURGE uses the same data format as PERISTALTIC_PRIME - (both send identical data bytes). - - Args: - plate: PLR Plate resource. - volume: Purge volume in microliters. - duration: Fixed duration in seconds (alternative to volume). - flow_rate: Flow rate ("Low", "Medium", or "High"). - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). - - Raises: - ValueError: If both volume and duration are specified, or if neither is given. - """ - if volume is not None and duration is not None: - raise ValueError("Specify either volume or duration, not both.") - if volume is None and duration is None: - raise ValueError("Either volume or duration must be specified.") - - if duration is not None: - if not 1 <= duration <= 300: - raise ValueError("duration must be 1-300 seconds") - purge_volume = 0.0 - purge_duration = duration - else: - assert volume is not None # guaranteed by the mutual-exclusion check above - if not 1 <= volume <= 3000: - raise ValueError("volume must be 1-3000 uL (GUI limit)") - purge_volume = volume - purge_duration = 0 - - validate_peristaltic_flow_rate(flow_rate) - - logger.info( - "Peristaltic purge: %.1f uL, flow rate %s, cassette %s", - purge_volume, - flow_rate, - cassette, - ) - - # Reuse peristaltic_prime builder since data format is identical - data = self._build_peristaltic_prime_command( - plate=plate, - volume=purge_volume, - duration=purge_duration, - flow_rate=PERISTALTIC_FLOW_RATE_MAP[flow_rate], - reverse=True, - cassette=cassette, - pump=1, - ) - framed_command = build_framed_message(command=0x91, data=data) - # Timeout: duration (if specified) + buffer for volume-based purging - purge_timeout = self.timeout + purge_duration + 30 - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=purge_timeout) - - # ========================================================================= - # COMMAND BUILDERS - # ========================================================================= - - def _build_peristaltic_prime_command( - self, - plate: Plate, - volume: float, - duration: int = 0, - flow_rate: int = 2, - reverse: bool = True, - cassette: Cassette = "Any", - pump: int = 1, - ) -> bytes: - """Build peristaltic prime command bytes. - - Protocol format (11 bytes): - Example: 04 2c 01 00 00 02 01 00 01 00 00 - - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1-2] Volume (LE) — 0x0000 when using duration mode - [3-4] Duration in seconds (LE) — 0x0000 when using volume mode - [5] Flow rate enum (0=Low, 1=Medium, 2=High) - [6] Reverse/submerge (0 or 1) - [7] Cassette type (Any: 0, 1uL: 1, 5uL: 2, 10uL: 3) - [8] Pump (Primary: 1, Secondary: 2) - [9-10] Padding (0x0000) - - Args: - volume: Prime volume in microliters (0 when using duration mode). - duration: Fixed duration in seconds (0 when using volume mode). - flow_rate: Flow rate (0=Low, 1=Medium, 2=High). - reverse: Whether to reverse/submerge after prime. - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). - pump: Pump (1=Primary, 2=Secondary). - - Returns: - Command bytes (11 bytes). - """ - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u16(int(volume)) # [1-2] Volume (LE) - .u16(duration) # [3-4] Duration (LE) - .u8(flow_rate) # [5] Flow rate - .u8(1 if reverse else 0) # [6] Reverse/submerge - .u8(cassette_to_byte(cassette)) # [7] Cassette type - .u8(pump & 0xFF) # [8] Pump - .raw_bytes(b'\x00' * 2) # [9-10] Padding - .finish() - ) # fmt: skip - - def _build_peristaltic_dispense_command( - self, - plate: Plate, - volume: float, - flow_rate: int, - cassette: Cassette = "Any", - offset_x: int = 0, - offset_y: int = 0, - offset_z: int = 336, - pre_dispense_volume: float = 0.0, - num_pre_dispenses: int = 2, - column_mask: list[int] | None = None, - rows: list[int] | None = None, - pump: int = 1, - ) -> bytes: - """Build peristaltic dispense command bytes. - - Protocol format (24 bytes): - Example: 04 0a 00 02 00 00 00 50 01 0a 00 02 ff ff ff ff ff ff 00 01 00 00 00 00 - - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1-2] Volume (LE) - [3] Flow rate (0=Low, 1=Med, 2=High) - [4] Cassette type (Any: 0, 1uL: 1, 5uL: 2, 10uL: 3) - [5] Offset X (signed byte) - [6] Offset Y (signed byte) - [7-8] Offset Z (LE) - [9-10] Pre-dispense volume (LE, 0 if disabled) - [11] Num pre-dispenses - [12-17] Column mask (48 bits packed, normal: 1=selected) - [18] Row mask (4 bits packed, INVERTED: 0=selected, 1=deselected) - [19] Pump (Primary: 1, Secondary: 2) - [20-23] Padding - - Args: - volume: Dispense volume in microliters. - flow_rate: Flow rate (0=Low, 1=Medium, 2=High). - cassette: Cassette type ("Any", "1uL", "5uL", "10uL"). - offset_x: X offset (signed, 0.1mm units). - offset_y: Y offset (signed, 0.1mm units). - offset_z: Z offset (0.1mm units). - pre_dispense_volume: Pre-dispense volume in uL. - num_pre_dispenses: Number of pre-dispenses (default 2). - column_mask: List of column indices (0-47) or None for all columns. - rows: List of row numbers (1-4) or None for all rows. - pump: Pump (1=Primary, 2=Secondary). - - Returns: - Command bytes (24 bytes). - """ - num_row_groups = plate_max_row_groups(plate) - - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u16(int(volume)) # [1-2] Volume (LE) - .u8(flow_rate) # [3] Flow rate - .u8(cassette_to_byte(cassette)) # [4] Cassette type - .i8(offset_x) # [5] Offset X - .i8(offset_y) # [6] Offset Y - .u16(offset_z) # [7-8] Offset Z (LE) - .u16(int(pre_dispense_volume)) # [9-10] Pre-dispense vol - .u8(num_pre_dispenses) # [11] Num pre-dispenses - .raw_bytes(encode_column_mask(column_mask)) # [12-17] Column mask - .u8(encode_quadrant_mask_inverted(rows, num_row_groups=num_row_groups)) # [18] Row mask - .u8(pump & 0xFF) # [19] Pump - .raw_bytes(b'\x00' * 4) # [20-23] Padding - .finish() - ) # fmt: skip diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_shake.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_shake.py index d2323447614..01a95fb2b9a 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_shake.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_shake.py @@ -1,151 +1,10 @@ -"""EL406 shake/soak step methods. +"""EL406 shake/soak step methods — legacy wrapper. -Provides the shake operation and its command builder. +Implementation has moved to pylabrobot.agilent.biotek.el406.shaking_backend. """ -from __future__ import annotations - -import logging -from typing import Literal - -from pylabrobot.io.binary import Writer -from pylabrobot.resources import Plate - -from ..helpers import plate_to_wire_byte -from ..protocol import build_framed_message -from ._base import EL406StepsBaseMixin - -INTENSITY_TO_BYTE: dict[str, int] = { - "Variable": 0x01, - "Slow": 0x02, - "Medium": 0x03, - "Fast": 0x04, -} - -logger = logging.getLogger(__name__) - - -Intensity = Literal["Variable", "Slow", "Medium", "Fast"] - - -def validate_intensity(intensity: Intensity) -> None: - if intensity not in {"Slow", "Medium", "Fast", "Variable"}: - raise ValueError( - f"intensity must be one of {sorted({'Slow', 'Medium', 'Fast', 'Variable'})}, got {intensity!r}" - ) - - -class EL406ShakeStepsMixin(EL406StepsBaseMixin): - """Mixin for shake/soak step operations.""" - - MAX_SHAKE_DURATION = 3599 # 59:59 max (mm:ss format, mm max=59) - MAX_SOAK_DURATION = 3599 # 59:59 max (mm:ss format, mm max=59) - - async def shake( - self, - plate: Plate, - duration: int = 0, - intensity: Intensity = "Medium", - soak_duration: int = 0, - move_home_first: bool = True, - ) -> None: - """Shake the plate with optional soak period. - - Durations are in whole seconds (GUI uses mm:ss picker, max 59:59 each). - A duration of 0 disables shake. A soak_duration of 0 disables soak. - - Note: The GUI forces move_home_first=True when total time exceeds 60s - to prevent manifold drip contamination. Our default of True matches this. - - Args: - plate: PLR Plate resource. - duration: Shake duration in seconds (0-3599). 0 to disable shake. - intensity: Shake intensity - "Variable", "Slow" (3.5 Hz), - "Medium" (5 Hz), or "Fast" (8 Hz). - soak_duration: Soak duration in seconds after shaking (0-3599). 0 to disable. - move_home_first: Move carrier to home position before shaking (default True). - - Raises: - ValueError: If parameters are invalid. - """ - if duration < 0 or duration > self.MAX_SHAKE_DURATION: - raise ValueError(f"Invalid duration {duration}. Must be 0-{self.MAX_SHAKE_DURATION}.") - if soak_duration < 0 or soak_duration > self.MAX_SOAK_DURATION: - raise ValueError( - f"Invalid soak_duration {soak_duration}. Must be 0-{self.MAX_SOAK_DURATION}." - ) - if duration == 0 and soak_duration == 0: - raise ValueError("At least one of duration or soak_duration must be > 0.") - validate_intensity(intensity) - - shake_enabled = duration > 0 - - logger.info( - "Shake: %ds, %s intensity, move_home=%s, soak=%ds", - duration, - intensity, - move_home_first, - soak_duration, - ) - - data = self._build_shake_command( - plate=plate, - shake_duration=duration, - soak_duration=soak_duration, - intensity=intensity, - shake_enabled=shake_enabled, - move_home_first=move_home_first, - ) - framed_command = build_framed_message(command=0xA3, data=data) - total_timeout = duration + soak_duration + self.timeout - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=total_timeout) - - # ========================================================================= - # COMMAND BUILDERS - # ========================================================================= - - def _build_shake_command( - self, - plate: Plate, - shake_duration: int = 0, - soak_duration: int = 0, - intensity: Intensity = "Medium", - shake_enabled: bool = True, - move_home_first: bool = True, - ) -> bytes: - """Build shake command bytes. - - Byte structure (12 bytes): - [0] Plate type - [1] move_home_first: 0x00 or 0x01 - [2-3] Shake duration in total seconds (16-bit LE) - [4] Intensity: 0x01=Variable, 0x02=Slow, 0x03=Medium, 0x04=Fast - [5] Reserved: 0x00 - [6-7] Soak duration in total seconds (16-bit LE) - [8-11] Padding (4 bytes) - - Args: - plate: PLR Plate resource. - shake_duration: Shake duration in seconds. - soak_duration: Soak duration in seconds. - intensity: Shake intensity ("Variable", "Slow", "Medium", "Fast"). - shake_enabled: Whether shake is enabled. When False, shake_duration is not encoded. - move_home_first: Move carrier to home position before shaking (default True). - - Returns: - Command bytes (12 bytes). - """ - shake_total_seconds = int(shake_duration) if shake_enabled else 0 - - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(0x01 if move_home_first else 0x00) # [1] move_home_first - .u16(shake_total_seconds) # [2-3] Shake duration (seconds) - .u8(INTENSITY_TO_BYTE.get(intensity, 0x03)) # [4] Intensity - .u8(0x00) # [5] Reserved - .u16(int(soak_duration)) # [6-7] Soak duration (seconds) - .raw_bytes(b'\x00' * 4) # [8-11] Padding - .finish() - ) # fmt: skip +from pylabrobot.agilent.biotek.el406.shaking_backend import ( # noqa: F401 + INTENSITY_TO_BYTE, + Intensity, + validate_intensity, +) diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_syringe.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_syringe.py index d9d4ca184a8..60f2a666455 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/steps/_syringe.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps/_syringe.py @@ -1,344 +1,4 @@ -"""EL406 syringe pump step methods. +"""EL406 syringe pump step methods — legacy wrapper. -Provides syringe_dispense and syringe_prime operations -plus their corresponding command builders. +Implementation has moved to pylabrobot.agilent.biotek.el406.syringe_dispensing_backend. """ - -from __future__ import annotations - -import logging -from typing import Literal - -from pylabrobot.io.binary import Writer -from pylabrobot.resources import Plate - -from ..helpers import ( - plate_to_wire_byte, - plate_well_count, -) -from ..protocol import build_framed_message, columns_to_column_mask, encode_column_mask -from ._base import EL406StepsBaseMixin - -logger = logging.getLogger(__name__) - -Syringe = Literal["A", "B", "Both"] - - -def syringe_to_byte(syringe: Syringe) -> int: - syringe_upper = syringe.upper() - if syringe_upper == "A": - return 0 - if syringe_upper == "B": - return 1 - if syringe_upper == "BOTH": - return 2 - raise ValueError(f"Invalid syringe: {syringe}") - - -def validate_syringe(syringe: Syringe) -> None: - if syringe.upper() not in {"A", "B", "BOTH"}: - raise ValueError(f"Invalid syringe '{syringe}'. Must be one of: A, B, BOTH") - - -def validate_syringe_flow_rate(flow_rate: int) -> None: - if not 1 <= flow_rate <= 5: - raise ValueError(f"Syringe flow rate must be 1-5, got {flow_rate}") - - -def validate_syringe_volume(volume: float) -> None: - if not 80 <= volume <= 9999: - raise ValueError(f"Syringe volume must be 80-9999 uL, got {volume}") - - -def validate_pump_delay(delay: int) -> None: - if not 0 <= delay <= 5000: - raise ValueError(f"Pump delay must be 0-5000 ms, got {delay}") - - -def validate_submerge_duration(duration: int) -> None: - if not 0 <= duration <= 1439: - raise ValueError(f"Submerge duration must be 0-1439 minutes, got {duration}") - - -class EL406SyringeStepsMixin(EL406StepsBaseMixin): - """Mixin for syringe pump step operations.""" - - async def syringe_dispense( - self, - plate: Plate, - volume: float, - syringe: Syringe = "A", - flow_rate: int = 2, - offset_x: int = 0, - offset_y: int = 0, - offset_z: int = 336, - pump_delay: float = 0.0, - pre_dispense: bool = False, - pre_dispense_volume: float = 0.0, - num_pre_dispenses: int = 2, - columns: list[int] | None = None, - ) -> None: - """Dispense liquid using the syringe pump. - - Args: - plate: PLR Plate resource. - volume: Dispense volume in microliters per well. - Volume range depends on plate type: - - 96-well: 10-3000 uL - - 384-well: 5-1500 uL - - 1536-well: 3-3000 uL - syringe: Syringe selection — "A", "B", or "Both". - flow_rate: Flow rate (1-5). Maximum rate depends on volume and plate type. - For 96-well: rate 1 for 10+ uL, rate 2 for 20+ uL, rate 3 for 50+ uL, - rate 4 for 60+ uL, rate 5 for 80+ uL. - For 384-well: rate 1 for 5+ uL, rate 2 for 10+ uL, rate 3 for 25+ uL, - rate 4 for 30+ uL, rate 5 for 40+ uL. - For 1536-well: all rates for 3+ uL. - offset_x: X offset (signed, 0.1mm units). - offset_y: Y offset (signed, 0.1mm units). - offset_z: Z offset (0.1mm units, default 336 for 96-well, 254 for 1536-well). - pump_delay: Post-dispense delay in seconds (0-5). Wire resolution: 1 ms. - pre_dispense: Whether to enable pre-dispense mode. - pre_dispense_volume: Pre-dispense volume in uL/tube (only used if pre_dispense=True). - num_pre_dispenses: Number of pre-dispenses (default 2). - columns: List of 1-indexed column numbers to dispense to, or None for all columns. - For 96-well: 1-12, for 384-well: 1-24, for 1536-well: 1-48. - - Raises: - ValueError: If parameters are invalid. - """ - # Convert PLR units (seconds) to wire units (ms) - pump_delay_ms = round(pump_delay * 1000) - - if volume <= 0: - raise ValueError(f"volume must be positive, got {volume}") - validate_syringe(syringe) - validate_syringe_flow_rate(flow_rate) - validate_pump_delay(pump_delay_ms) - - column_mask = columns_to_column_mask(columns, plate_wells=plate_well_count(plate)) - - logger.info( - "Syringe dispense: %.1f uL from syringe %s, flow rate %d", - volume, - syringe, - flow_rate, - ) - - data = self._build_syringe_dispense_command( - plate=plate, - volume=volume, - syringe=syringe, - flow_rate=flow_rate, - offset_x=offset_x, - offset_y=offset_y, - offset_z=offset_z, - pump_delay_ms=pump_delay_ms, - pre_dispense=pre_dispense, - pre_dispense_volume=pre_dispense_volume, - num_pre_dispenses=num_pre_dispenses, - column_mask=column_mask, - ) - framed_command = build_framed_message(command=0xA1, data=data) - async with self.batch(plate): - await self._send_step_command(framed_command) - - async def syringe_prime( - self, - plate: Plate, - syringe: Literal["A", "B"] = "A", - volume: float = 5000.0, - flow_rate: int = 5, - refills: int = 2, - pump_delay: float = 0.0, - submerge_tips: bool = True, - submerge_duration: float = 0.0, - ) -> None: - """Prime the syringe pump system. - - Fills the syringe tubing by drawing and expelling liquid. - - Args: - plate: PLR Plate resource. - syringe: Syringe selection — "A" or "B". - volume: Volume to prime in microliters (80-9999). - flow_rate: Flow rate (1-5). - refills: Number of prime cycles (1-255). - pump_delay: Delay between cycles in seconds (0-5). Wire resolution: 1 ms. - submerge_tips: Submerge tips in fluid after prime (default True). - submerge_duration: Submerge duration in seconds (0-86340, i.e. up to 23:59). - 0 to disable submerge time. Only encoded when submerge_tips=True. - Wire resolution: 60 s (1 minute). - - Raises: - ValueError: If parameters are invalid. - """ - # Convert to wire units: seconds → milliseconds, seconds → minutes - pump_delay_ms = round(pump_delay * 1000) - if submerge_duration != 0 and submerge_duration % 60 != 0: - raise ValueError( - f"Submerge duration must be a multiple of 60 seconds (device resolution is 1 minute), " - f"got {submerge_duration}" - ) - submerge_duration_min = round(submerge_duration / 60) - - validate_syringe(syringe) - validate_syringe_volume(volume) - validate_syringe_flow_rate(flow_rate) - validate_pump_delay(pump_delay_ms) - validate_submerge_duration(submerge_duration_min) - if not 1 <= refills <= 255: - raise ValueError(f"refills must be 1-255, got {refills}") - - logger.info( - "Syringe prime: syringe %s, %.1f uL, flow rate %d, %d refills", - syringe, - volume, - flow_rate, - refills, - ) - - data = self._build_syringe_prime_command( - plate=plate, - volume=volume, - syringe=syringe, - flow_rate=flow_rate, - refills=refills, - pump_delay_ms=pump_delay_ms, - submerge_tips=submerge_tips, - submerge_duration_min=submerge_duration_min, - ) - framed_command = build_framed_message(command=0xA2, data=data) - # Timeout: base for priming + submerge duration + buffer - prime_timeout = self.timeout + submerge_duration + 30 - async with self.batch(plate): - await self._send_step_command(framed_command, timeout=prime_timeout) - - # ========================================================================= - # COMMAND BUILDERS - # ========================================================================= - - def _build_syringe_dispense_command( - self, - plate: Plate, - volume: float, - syringe: Syringe, - flow_rate: int, - offset_x: int = 0, - offset_y: int = 0, - offset_z: int = 336, - pump_delay_ms: int = 0, - pre_dispense: bool = False, - pre_dispense_volume: float = 0.0, - num_pre_dispenses: int = 2, - column_mask: list[int] | None = None, - ) -> bytes: - """Build syringe dispense command bytes. - - Wire format (26 bytes): - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1] Syringe: A=0, B=1, Both=2 - [2-3] Volume: 2 bytes, little-endian, in uL - [4] Flow rate: 1-5 - [5] Offset X: signed byte - [6] Offset Y: signed byte - [7-8] Offset Z: 2 bytes, little-endian - [9-10] Pump delay: 2 bytes, little-endian, in ms - [11-12] Pre-dispense volume: 2 bytes, little-endian (0 if pre_dispense=False) - [13] Number of pre-dispenses (default 2) - [14-19] Column mask: 6 bytes (48 bits packed) - [20] Bottle selection (A→0, B→2, Both→4) - [21-25] Padding (5 bytes) - - Args: - volume: Dispense volume in microliters. - syringe: Syringe selection (A, B, Both). - flow_rate: Flow rate (1-5). - offset_x: X offset (signed, 0.1mm units). - offset_y: Y offset (signed, 0.1mm units). - offset_z: Z offset (0.1mm units). - pump_delay_ms: Post-dispense delay in milliseconds. - pre_dispense: Whether to enable pre-dispense mode. - pre_dispense_volume: Pre-dispense volume in uL/tube (only used if pre_dispense=True). - num_pre_dispenses: Number of pre-dispenses (default 2). - column_mask: List of column indices (0-47) or None for all columns. - - Returns: - Command bytes (26 bytes). - """ - pre_disp_vol_int = int(pre_dispense_volume) if pre_dispense else 0 - bottle_byte = {"A": 0, "B": 2, "BOTH": 4}.get(syringe.upper(), 0) - - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(syringe_to_byte(syringe)) # [1] Syringe - .u16(int(volume)) # [2-3] Volume (LE) - .u8(flow_rate) # [4] Flow rate - .i8(offset_x) # [5] Offset X - .i8(offset_y) # [6] Offset Y - .u16(offset_z) # [7-8] Offset Z (LE) - .u16(pump_delay_ms) # [9-10] Pump delay (LE) - .u16(pre_disp_vol_int) # [11-12] Pre-dispense vol (LE) - .u8(num_pre_dispenses) # [13] Num pre-dispenses - .raw_bytes(encode_column_mask(column_mask)) # [14-19] Column mask - .u8(bottle_byte) # [20] Bottle selection - .raw_bytes(b'\x00' * 5) # [21-25] Padding - .finish() - ) # fmt: skip - - def _build_syringe_prime_command( - self, - plate: Plate, - volume: float, - syringe: Literal["A", "B"], - flow_rate: int, - refills: int = 2, - pump_delay_ms: int = 0, - submerge_tips: bool = True, - submerge_duration_min: int = 0, - ) -> bytes: - """Build syringe prime command bytes. - - Protocol format (13 bytes): - [0] Plate type (wire byte, e.g. 0x04=96-well) - [1] Syringe: A=0, B=1 - [2-3] Volume: 2 bytes, little-endian, in uL - [4] Flow rate: 1-5 - [5] Refills: byte (number of prime cycles) - [6-7] Pump delay: 2 bytes, little-endian, in ms - [8] Submerge tips (0 or 1) — "Submerge tips in fluid after prime" - [9-10] Submerge duration in minutes (LE uint16). 0 if submerge_tips=False. - [11] Bottle: derived from syringe (A->0, B->2) - [12] Padding - - Args: - volume: Prime volume in microliters. - syringe: Syringe selection (A, B). - flow_rate: Flow rate (1-5). - refills: Number of prime cycles. - pump_delay_ms: Delay between cycles in milliseconds (default 0). - submerge_tips: Submerge tips in fluid after prime (default True). - submerge_duration_min: Submerge duration in minutes (0-1439). Only encoded - when submerge_tips=True. - - Returns: - Command bytes (13 bytes). - """ - sub_total = submerge_duration_min if (submerge_tips and submerge_duration_min > 0) else 0 - bottle_byte = {"A": 0, "B": 2}.get(syringe.upper(), 0) - - return ( - Writer() - .u8(plate_to_wire_byte(plate)) # [0] Plate type - .u8(syringe_to_byte(syringe)) # [1] Syringe (A=0, B=1) - .u16(int(volume)) # [2-3] Volume (LE) - .u8(flow_rate) # [4] Flow rate - .u8(refills & 0xFF) # [5] Refills - .u16(pump_delay_ms) # [6-7] Pump delay (LE) - .u8(1 if submerge_tips else 0) # [8] Submerge tips - .u16(sub_total) # [9-10] Submerge duration (LE, minutes) - .u8(bottle_byte) # [11] Bottle selection - .u8(0x00) # [12] Padding - .finish() - ) # fmt: skip diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps_aspirate_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_aspirate_tests.py index c208cee3de8..f3fe08c5dd9 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/steps_aspirate_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_aspirate_tests.py @@ -95,7 +95,7 @@ def setUp(self): def test_aspirate_command_defaults(self): """Default aspirate: no vacuum, rate 3, delay 0, z=30.""" - cmd = self.backend._build_aspirate_command(PT96) + cmd = self.backend._plate_washing._build_aspirate_command(PT96) self.assertEqual(len(cmd), 22) self.assertEqual(cmd[0], 0x04) self.assertEqual(cmd[1], 0) # no vacuum @@ -119,7 +119,7 @@ def test_aspirate_command_defaults(self): def test_aspirate_command_vacuum_filtration(self): """Vacuum filtration flag should be set when enabled.""" - cmd = self.backend._build_aspirate_command(PT96, vacuum_filtration=True, time_value=30) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, vacuum_filtration=True, time_value=30) self.assertEqual(cmd[1], 1) # time_value=30 at bytes 2-3 self.assertEqual(cmd[2], 30) @@ -127,7 +127,7 @@ def test_aspirate_command_vacuum_filtration(self): def test_aspirate_command_delay_encoding(self): """Delay value should be encoded correctly.""" - cmd = self.backend._build_aspirate_command(PT96, time_value=5000) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, time_value=5000) # 5000 = 0x1388 self.assertEqual(cmd[2], 0x88) self.assertEqual(cmd[3], 0x13) @@ -135,37 +135,37 @@ def test_aspirate_command_delay_encoding(self): def test_aspirate_command_travel_rate(self): """Travel rate should be encoded correctly.""" # Normal rate "5" -> byte 5 - cmd = self.backend._build_aspirate_command(PT96, travel_rate_byte=5) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, travel_rate_byte=5) self.assertEqual(cmd[4], 5) # CW rate "2 CW" -> byte 8 - cmd = self.backend._build_aspirate_command(PT96, travel_rate_byte=8) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, travel_rate_byte=8) self.assertEqual(cmd[4], 8) def test_aspirate_command_negative_offset_x(self): """Negative X offset should be encoded as unsigned byte.""" - cmd = self.backend._build_aspirate_command(PT96, offset_x=-30) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, offset_x=-30) # -30 as unsigned byte = 226 = 0xE2 self.assertEqual(cmd[5], 226) def test_aspirate_command_positive_offset_y(self): """Positive Y offset should be encoded correctly.""" - cmd = self.backend._build_aspirate_command(PT96, offset_y=5) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, offset_y=5) self.assertEqual(cmd[6], 5) def test_aspirate_command_z_offset(self): """Z offset should be encoded correctly.""" - cmd = self.backend._build_aspirate_command(PT96, offset_z=121) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, offset_z=121) self.assertEqual(cmd[7], 121) self.assertEqual(cmd[8], 0) def test_aspirate_command_secondary_mode(self): """Secondary mode should be encoded correctly.""" - cmd = self.backend._build_aspirate_command(PT96, secondary_mode=1) + cmd = self.backend._plate_washing._build_aspirate_command(PT96, secondary_mode=1) self.assertEqual(cmd[9], 1) def test_aspirate_command_secondary_offsets(self): """Secondary offsets should be encoded correctly.""" - cmd = self.backend._build_aspirate_command( + cmd = self.backend._plate_washing._build_aspirate_command( PT96, secondary_x=-5, secondary_y=3, @@ -179,13 +179,13 @@ def test_aspirate_command_secondary_offsets(self): def test_aspirate_command_column_mask_all(self): """Column mask should select all columns for manifold aspirate.""" - cmd = self.backend._build_aspirate_command(PT96) + cmd = self.backend._plate_washing._build_aspirate_command(PT96) self.assertEqual(cmd[16], 0xFF) # all 12 columns self.assertEqual(cmd[17], 0x0F) def test_aspirate_command_length(self): """Aspirate command should be exactly 22 bytes.""" - cmd = self.backend._build_aspirate_command(PT96) + cmd = self.backend._plate_washing._build_aspirate_command(PT96) self.assertEqual(len(cmd), 22) diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps_peristaltic_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_peristaltic_tests.py index e6ce2b2d0e9..2686ee9a79c 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/steps_peristaltic_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_peristaltic_tests.py @@ -81,7 +81,7 @@ def setUp(self): def test_peristaltic_dispense_step_type(self): """Peristaltic dispense command should have step type prefix 0x04.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -91,7 +91,7 @@ def test_peristaltic_dispense_step_type(self): def test_peristaltic_dispense_volume_encoding(self): """Peristaltic dispense should encode volume as little-endian 2 bytes.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -103,7 +103,7 @@ def test_peristaltic_dispense_volume_encoding(self): def test_peristaltic_dispense_volume_1000ul(self): """Peristaltic dispense with 1000 uL.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=1000.0, flow_rate=5, @@ -115,7 +115,7 @@ def test_peristaltic_dispense_volume_1000ul(self): def test_peristaltic_dispense_flow_rate_at_byte3(self): """Peristaltic dispense flow rate should be at byte 3.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -125,7 +125,7 @@ def test_peristaltic_dispense_flow_rate_at_byte3(self): def test_peristaltic_dispense_cassette_at_byte4(self): """Peristaltic dispense cassette type should be at byte 4.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -137,7 +137,7 @@ def test_peristaltic_dispense_cassette_at_byte4(self): def test_peristaltic_dispense_offset_z(self): """Peristaltic dispense should encode Z offset as little-endian 2 bytes.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -150,7 +150,7 @@ def test_peristaltic_dispense_offset_z(self): def test_peristaltic_dispense_offset_x_positive(self): """Peristaltic dispense should encode positive X offset at byte 5.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -161,7 +161,7 @@ def test_peristaltic_dispense_offset_x_positive(self): def test_peristaltic_dispense_offset_x_negative(self): """Peristaltic dispense should encode negative X offset as two's complement at byte 5.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -173,7 +173,7 @@ def test_peristaltic_dispense_offset_x_negative(self): def test_peristaltic_dispense_offset_y_negative(self): """Peristaltic dispense should encode negative Y offset as two's complement at byte 6.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -185,7 +185,7 @@ def test_peristaltic_dispense_offset_y_negative(self): def test_peristaltic_dispense_pre_dispense_volume(self): """Peristaltic dispense should encode prime volume as little-endian 2 bytes.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -198,7 +198,7 @@ def test_peristaltic_dispense_pre_dispense_volume(self): def test_peristaltic_dispense_num_pre_dispenses_default(self): """Peristaltic dispense should encode default num_pre_dispenses (2) at byte 11.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=7, @@ -209,7 +209,7 @@ def test_peristaltic_dispense_num_pre_dispenses_default(self): def test_peristaltic_dispense_num_pre_dispenses_1(self): """Peristaltic dispense should encode num_pre_dispenses=1 at byte 11.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=1, @@ -220,7 +220,7 @@ def test_peristaltic_dispense_num_pre_dispenses_1(self): def test_peristaltic_dispense_num_pre_dispenses_5(self): """Peristaltic dispense should encode num_pre_dispenses=5 at byte 11.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=9, @@ -231,7 +231,7 @@ def test_peristaltic_dispense_num_pre_dispenses_5(self): def test_peristaltic_dispense_full_command(self): """Test complete peristaltic dispense command with all parameters.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=500.0, flow_rate=7, @@ -352,7 +352,7 @@ def setUp(self): def test_peristaltic_dispense_command_with_column_mask_length(self): """Command with well mask should be 24 bytes.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -362,7 +362,7 @@ def test_peristaltic_dispense_command_with_column_mask_length(self): def test_peristaltic_dispense_command_column_mask_encoding(self): """Command should correctly encode well mask at bytes 12-17.""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -375,7 +375,7 @@ def test_peristaltic_dispense_command_column_mask_encoding(self): def test_peristaltic_dispense_command_pump_at_byte19(self): """Pump should be at byte 19 (1=Primary, 2=Secondary).""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -385,7 +385,7 @@ def test_peristaltic_dispense_command_pump_at_byte19(self): def test_peristaltic_dispense_command_none_column_mask_all_wells(self): """Command with None column_mask should encode all wells (0xFF * 6).""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -395,7 +395,7 @@ def test_peristaltic_dispense_command_none_column_mask_all_wells(self): def test_peristaltic_dispense_command_default_row_mask(self): """Default rows=None should encode 0x00 (all selected, inverted).""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -404,7 +404,7 @@ def test_peristaltic_dispense_command_default_row_mask(self): def test_peristaltic_dispense_command_default_pump(self): """Default pump should be 1 (Primary).""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -413,7 +413,7 @@ def test_peristaltic_dispense_command_default_pump(self): def test_peristaltic_dispense_command_empty_column_mask(self): """Command with empty column_mask should encode no wells (0x00 * 6).""" - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -425,7 +425,7 @@ def test_peristaltic_dispense_command_rows_inverted_encoding(self): """Row mask uses inverted encoding: 0=selected, 1=deselected.""" # Use 1536-well plate type which supports 4 row groups # Select rows 1 and 2 -> bits 0,1 cleared, bits 2,3 set -> 0x0C - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT1536, volume=300.0, flow_rate=5, @@ -436,7 +436,7 @@ def test_peristaltic_dispense_command_rows_inverted_encoding(self): def test_peristaltic_dispense_command_complex_column_mask(self): """Command with complex well mask spanning multiple bytes.""" # Wells 0, 8, 16, 24, 32, 40 = bit 0 of each of the 6 bytes - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT96, volume=300.0, flow_rate=5, @@ -453,7 +453,7 @@ def test_peristaltic_dispense_command_complex_column_mask(self): def test_peristaltic_dispense_command_both_masks(self): """Command with column_mask and rows.""" # Use 1536-well plate type which supports 4 row groups - cmd = self.backend._build_peristaltic_dispense_command( + cmd = self.backend._peristaltic._build_peristaltic_dispense_command( PT1536, volume=500.0, flow_rate=7, diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps_prime_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_prime_tests.py index 054ce35673b..7f1105f5aa8 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/steps_prime_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_prime_tests.py @@ -191,7 +191,7 @@ def setUp(self): def test_syringe_prime_step_type(self): """Syringe prime command should have prefix 0x04.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -201,7 +201,7 @@ def test_syringe_prime_step_type(self): def test_syringe_prime_syringe_a(self): """Syringe prime syringe A should encode as 0.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -211,7 +211,7 @@ def test_syringe_prime_syringe_a(self): def test_syringe_prime_syringe_b(self): """Syringe prime syringe B should encode as 1.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="B", @@ -221,7 +221,7 @@ def test_syringe_prime_syringe_b(self): def test_syringe_prime_lowercase_syringe(self): """Syringe prime should accept lowercase syringe names.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="b", @@ -231,7 +231,7 @@ def test_syringe_prime_lowercase_syringe(self): def test_syringe_prime_volume_encoding(self): """Syringe prime should encode volume as little-endian 2 bytes.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -242,7 +242,7 @@ def test_syringe_prime_volume_encoding(self): def test_syringe_prime_volume_1000ul(self): """Syringe prime with 1000 uL.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=1000.0, syringe="A", @@ -254,7 +254,7 @@ def test_syringe_prime_volume_1000ul(self): def test_syringe_prime_flow_rate(self): """Syringe prime should encode flow rate as single byte.""" for rate in [1, 3, 5]: - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -264,7 +264,7 @@ def test_syringe_prime_flow_rate(self): def test_syringe_prime_refills(self): """Syringe prime should encode refills as single byte.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -275,7 +275,7 @@ def test_syringe_prime_refills(self): def test_syringe_prime_default_refills(self): """Syringe prime should default to 2 refills.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -285,7 +285,7 @@ def test_syringe_prime_default_refills(self): def test_syringe_prime_pump_delay(self): """Syringe prime should encode pump delay as LE uint16.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -298,7 +298,7 @@ def test_syringe_prime_pump_delay(self): def test_syringe_prime_command_length(self): """Syringe prime command should have exactly 13 bytes.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=5000.0, syringe="A", @@ -308,7 +308,7 @@ def test_syringe_prime_command_length(self): def test_syringe_prime_full_command(self): """Test complete syringe prime command with all parameters.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=3000.0, syringe="B", @@ -336,13 +336,13 @@ def test_syringe_prime_full_command(self): def test_syringe_prime_bottle_encoding(self): """Test syringe prime encodes bottle from syringe selection.""" - cmd_a = self.backend._build_syringe_prime_command( + cmd_a = self.backend._syringe._build_syringe_prime_command( PT96, volume=1000.0, syringe="A", flow_rate=5, ) - cmd_b = self.backend._build_syringe_prime_command( + cmd_b = self.backend._syringe._build_syringe_prime_command( PT96, volume=1000.0, syringe="B", @@ -353,7 +353,7 @@ def test_syringe_prime_bottle_encoding(self): def test_syringe_prime_submerge_duration(self): """Test syringe prime encodes submerge duration at bytes 9-10.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=1000.0, syringe="A", @@ -368,7 +368,7 @@ def test_syringe_prime_submerge_duration(self): def test_syringe_prime_submerge_disabled_zeroes_time(self): """When submerge_tips=False, time bytes should be zero.""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=1000.0, syringe="A", @@ -382,7 +382,7 @@ def test_syringe_prime_submerge_disabled_zeroes_time(self): def test_syringe_prime_submerge_max_duration(self): """Test max submerge duration (1439 minutes = 23:59).""" - cmd = self.backend._build_syringe_prime_command( + cmd = self.backend._syringe._build_syringe_prime_command( PT96, volume=1000.0, syringe="A", @@ -473,7 +473,7 @@ def setUp(self): def test_manifold_prime_step_type(self): """Manifold prime command should have step type prefix 0x04.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_manifold_prime_command( PT96, volume_ml=1000.0, buffer="A", @@ -484,7 +484,7 @@ def test_manifold_prime_step_type(self): def test_manifold_prime_buffer_a(self): """Manifold prime buffer A should encode as 'A' (0x41).""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_manifold_prime_command( PT96, volume_ml=1000.0, buffer="A", @@ -495,7 +495,7 @@ def test_manifold_prime_buffer_a(self): def test_manifold_prime_buffer_b(self): """Manifold prime buffer B should encode as 'B' (0x42).""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_manifold_prime_command( PT96, volume_ml=1000.0, buffer="B", @@ -506,7 +506,7 @@ def test_manifold_prime_buffer_b(self): def test_manifold_prime_lowercase_buffer(self): """Manifold prime should accept lowercase buffer and encode as uppercase.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_manifold_prime_command( PT96, volume_ml=1000.0, buffer="b", @@ -517,7 +517,7 @@ def test_manifold_prime_lowercase_buffer(self): def test_manifold_prime_volume_encoding(self): """Manifold prime should encode volume as little-endian 2 bytes.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_manifold_prime_command( PT96, volume_ml=1000.0, buffer="A", @@ -530,7 +530,7 @@ def test_manifold_prime_volume_encoding(self): def test_manifold_prime_volume_500ml(self): """Manifold prime with 500 mL.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_manifold_prime_command( PT96, volume_ml=500.0, buffer="A", @@ -543,7 +543,7 @@ def test_manifold_prime_volume_500ml(self): def test_manifold_prime_volume_max(self): """Manifold prime with maximum volume (65535 mL).""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_manifold_prime_command( PT96, volume_ml=65535.0, buffer="A", @@ -555,7 +555,7 @@ def test_manifold_prime_volume_max(self): def test_manifold_prime_flow_rate(self): """Manifold prime should encode flow rate as single byte.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_manifold_prime_command( PT96, volume_ml=1000.0, buffer="A", @@ -566,7 +566,7 @@ def test_manifold_prime_flow_rate(self): def test_manifold_prime_flow_rate_min(self): """Manifold prime should encode minimum flow rate 1.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_manifold_prime_command( PT96, volume_ml=1000.0, buffer="A", @@ -577,7 +577,7 @@ def test_manifold_prime_flow_rate_min(self): def test_manifold_prime_flow_rate_max(self): """Manifold prime should encode maximum flow rate 9.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_manifold_prime_command( PT96, volume_ml=1000.0, buffer="A", @@ -588,7 +588,7 @@ def test_manifold_prime_flow_rate_max(self): def test_manifold_prime_full_command(self): """Test complete manifold prime command with all parameters.""" - cmd = self.backend._build_manifold_prime_command( + cmd = self.backend._plate_washing._build_manifold_prime_command( PT96, volume_ml=2000.0, buffer="B", @@ -663,31 +663,31 @@ def setUp(self): def test_auto_clean_step_type(self): """Auto-clean command should have step type prefix 0x04.""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="A") + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="A") self.assertEqual(cmd[0], 0x04) def test_auto_clean_buffer_a(self): """Auto-clean buffer A should encode as 'A' (0x41).""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="A") + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="A") self.assertEqual(cmd[1], ord("A")) def test_auto_clean_buffer_b(self): """Auto-clean buffer B should encode as 'B' (0x42).""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="B") + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="B") self.assertEqual(cmd[1], ord("B")) def test_auto_clean_lowercase_buffer(self): """Auto-clean should accept lowercase buffer and encode as uppercase.""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="c") + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="c") self.assertEqual(cmd[1], ord("C")) def test_auto_clean_duration_encoding(self): """Auto-clean should encode duration as little-endian 2 bytes.""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="A", duration_min=60.0) + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="A", duration_min=60.0) # 60 = 0x003C LE self.assertEqual(cmd[2], 0x3C) @@ -695,7 +695,7 @@ def test_auto_clean_duration_encoding(self): def test_auto_clean_duration_30_minutes(self): """Auto-clean with 30 minute duration.""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="A", duration_min=30.0) + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="A", duration_min=30.0) # 30 = 0x001E LE self.assertEqual(cmd[2], 0x1E) @@ -703,14 +703,14 @@ def test_auto_clean_duration_30_minutes(self): def test_auto_clean_duration_zero(self): """Auto-clean with zero duration (no additional cleaning time).""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="A", duration_min=0.0) + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="A", duration_min=0.0) self.assertEqual(cmd[2], 0x00) self.assertEqual(cmd[3], 0x00) def test_auto_clean_full_command(self): """Test complete auto-clean command with all parameters.""" - cmd = self.backend._build_auto_clean_command( + cmd = self.backend._plate_washing._build_auto_clean_command( PT96, buffer="B", duration_min=90.0, @@ -723,7 +723,7 @@ def test_auto_clean_full_command(self): def test_auto_clean_default_duration(self): """Auto-clean without duration should use default 1 minute.""" - cmd = self.backend._build_auto_clean_command(PT96, buffer="A") + cmd = self.backend._plate_washing._build_auto_clean_command(PT96, buffer="A") self.assertEqual(cmd[2], 0x01) self.assertEqual(cmd[3], 0x00) diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps_shake_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_shake_tests.py index 799ad9d641c..cbb6e11f94e 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/steps_shake_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_shake_tests.py @@ -70,7 +70,7 @@ def setUp(self): def test_shake_command_basic(self): """Basic shake: 10 seconds, medium intensity.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=10.0, soak_duration=0.0, @@ -90,7 +90,7 @@ def test_shake_command_basic(self): def test_shake_command_variable_intensity(self): """Variable intensity encoding.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=0.0, @@ -109,7 +109,7 @@ def test_shake_command_encoding_durations(self): ] for duration, expected_hex in cases: with self.subTest(duration=duration): - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=duration, soak_duration=0.0, @@ -121,7 +121,7 @@ def test_shake_command_encoding_durations(self): def test_shake_command_encoding_shake_disabled(self): """Shake disabled should zero the duration.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=0.0, @@ -135,7 +135,7 @@ def test_shake_command_encoding_shake_disabled(self): def test_shake_command_encoding_move_home_false(self): """Verify encoding with move_home_first=false.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=0.0, @@ -149,7 +149,7 @@ def test_shake_command_encoding_move_home_false(self): def test_shake_command_encoding_soak_30s(self): """Verify encoding with 30s soak duration.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=30.0, @@ -163,7 +163,7 @@ def test_shake_command_encoding_soak_30s(self): def test_shake_command_encoding_soak_60s(self): """Verify encoding with 60s soak duration.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=60.0, @@ -177,7 +177,7 @@ def test_shake_command_encoding_soak_60s(self): def test_shake_command_encoding_slow_frequency(self): """Verify encoding with slow intensity.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=0.0, @@ -191,7 +191,7 @@ def test_shake_command_encoding_slow_frequency(self): def test_shake_command_encoding_fast_frequency(self): """Verify encoding with fast intensity.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=0.0, @@ -205,7 +205,7 @@ def test_shake_command_encoding_fast_frequency(self): def test_shake_command_encoding_complex(self): """Verify encoding with combined shake, soak, and slow intensity.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=300.0, soak_duration=120.0, @@ -219,7 +219,7 @@ def test_shake_command_encoding_complex(self): def test_shake_command_encoding_move_home_false_with_soak(self): """Verify encoding with move_home_first=false and soak.""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=30.0, soak_duration=60.0, @@ -233,7 +233,7 @@ def test_shake_command_encoding_move_home_false_with_soak(self): def test_shake_command_max_duration_encoding(self): """Verify encoding with maximum duration (3599s).""" - cmd = self.backend._build_shake_command( + cmd = self.backend._shaking._build_shake_command( PT96, shake_duration=3599, soak_duration=3599, diff --git a/pylabrobot/legacy/plate_washing/biotek/el406/steps_wash_tests.py b/pylabrobot/legacy/plate_washing/biotek/el406/steps_wash_tests.py index e7a1410d5db..9f0be020481 100644 --- a/pylabrobot/legacy/plate_washing/biotek/el406/steps_wash_tests.py +++ b/pylabrobot/legacy/plate_washing/biotek/el406/steps_wash_tests.py @@ -153,12 +153,12 @@ def setUp(self): def test_composite_command_length(self): """Composite wash command should produce the expected payload length.""" - cmd = self.backend._build_wash_composite_command(PT96) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96) self.assertEqual(len(cmd), 102) def test_composite_command_aspirate_sections(self): """Aspirate sections should encode travel rate and Z offsets.""" - cmd = self.backend._build_wash_composite_command(PT96, aspirate_travel_rate=5, aspirate_z=40) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, aspirate_travel_rate=5, aspirate_z=40) # Aspirate section 1 (final aspirate, mirrors primary Z) self.assertEqual(cmd[29], 5) # travel rate (propagated) self.assertEqual(cmd[32], 0x28) # Z low (40) @@ -174,21 +174,21 @@ def test_composite_command_aspirate_sections(self): def test_composite_command_final_section(self): """Final section should have shake intensity at the expected position.""" - cmd = self.backend._build_wash_composite_command(PT96, aspirate_travel_rate=3) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, aspirate_travel_rate=3) self.assertEqual(cmd[90], 3) # shake intensity (default Medium=3) self.assertEqual(cmd[91], 0x00) def test_composite_command_final_aspirate_flag(self): """Final aspirate flag should be encoded in the header.""" - cmd_on = self.backend._build_wash_composite_command(PT96, final_aspirate=True) + cmd_on = self.backend._plate_washing._build_wash_composite_command(PT96, final_aspirate=True) self.assertEqual(cmd_on[2], 0x01) - cmd_off = self.backend._build_wash_composite_command(PT96, final_aspirate=False) + cmd_off = self.backend._plate_washing._build_wash_composite_command(PT96, final_aspirate=False) self.assertEqual(cmd_off[2], 0x00) def test_composite_command_pre_dispense_volume(self): """Pre-dispense volume should be encoded in both dispense sections.""" - cmd = self.backend._build_wash_composite_command(PT96, pre_dispense_volume=100.0) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, pre_dispense_volume=100.0) # Dispense1 self.assertEqual(cmd[15], 0x64) # 100 low self.assertEqual(cmd[16], 0x00) # 100 high @@ -198,7 +198,7 @@ def test_composite_command_pre_dispense_volume(self): def test_composite_command_vacuum_delay_volume(self): """Vacuum delay volume should be encoded in both dispense sections.""" - cmd = self.backend._build_wash_composite_command(PT96, vacuum_delay_volume=200.0) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, vacuum_delay_volume=200.0) # Dispense1 self.assertEqual(cmd[18], 0xC8) # 200 low self.assertEqual(cmd[19], 0x00) # 200 high @@ -208,13 +208,13 @@ def test_composite_command_vacuum_delay_volume(self): def test_composite_command_aspirate_delay(self): """Final aspirate section should always have delay=0.""" - cmd = self.backend._build_wash_composite_command(PT96, aspirate_delay_ms=1000) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, aspirate_delay_ms=1000) self.assertEqual(cmd[30], 0x00) self.assertEqual(cmd[31], 0x00) def test_composite_command_aspirate_offsets(self): """Aspirate X/Y offsets should only appear in the primary aspirate section.""" - cmd = self.backend._build_wash_composite_command(PT96, aspirate_x=15, aspirate_y=-10) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, aspirate_x=15, aspirate_y=-10) # Final aspirate: X/Y fixed at 0 self.assertEqual(cmd[34], 0x00) self.assertEqual(cmd[35], 0x00) @@ -224,47 +224,47 @@ def test_composite_command_aspirate_offsets(self): def test_composite_command_shake_duration(self): """Shake duration should be encoded correctly.""" - cmd = self.backend._build_wash_composite_command(PT96, shake_duration=30) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, shake_duration=30) self.assertEqual(cmd[88], 30) self.assertEqual(cmd[89], 0x00) def test_composite_command_shake_intensity(self): """Shake intensity should be encoded correctly for each level.""" - cmd_fast = self.backend._build_wash_composite_command( + cmd_fast = self.backend._plate_washing._build_wash_composite_command( PT96, shake_duration=10, shake_intensity="Fast" ) self.assertEqual(cmd_fast[90], 0x04) - cmd_slow = self.backend._build_wash_composite_command( + cmd_slow = self.backend._plate_washing._build_wash_composite_command( PT96, shake_duration=10, shake_intensity="Slow" ) self.assertEqual(cmd_slow[90], 0x02) - cmd_var = self.backend._build_wash_composite_command( + cmd_var = self.backend._plate_washing._build_wash_composite_command( PT96, shake_duration=10, shake_intensity="Variable" ) self.assertEqual(cmd_var[90], 0x01) def test_composite_command_shake_intensity_default_when_disabled(self): """Shake intensity should stay at default when shake_duration=0.""" - cmd = self.backend._build_wash_composite_command(PT96, shake_duration=0, shake_intensity="Fast") + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, shake_duration=0, shake_intensity="Fast") self.assertEqual(cmd[90], 0x03) def test_composite_command_soak_duration(self): """Soak duration should be encoded correctly.""" - cmd = self.backend._build_wash_composite_command(PT96, soak_duration=90) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, soak_duration=90) self.assertEqual(cmd[92], 90) self.assertEqual(cmd[93], 0x00) def test_composite_command_soak_duration_large(self): """Large soak duration should encode correctly as 16-bit LE.""" - cmd = self.backend._build_wash_composite_command(PT96, soak_duration=3599) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, soak_duration=3599) self.assertEqual(cmd[92], 0x0F) self.assertEqual(cmd[93], 0x0E) def test_composite_command_all_new_params(self): """All new parameters set to non-default values should produce correct output.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, cycles=5, buffer="B", @@ -322,25 +322,25 @@ def setUp(self): def test_move_home_default_disabled(self): """move_home_first should default to False.""" - cmd = self.backend._build_wash_composite_command(PT96) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96) self.assertEqual(cmd[87], 0x00) def test_move_home_enabled(self): """move_home_first=True should set the move-home flag.""" - cmd = self.backend._build_wash_composite_command(PT96, move_home_first=True) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, move_home_first=True) self.assertEqual(cmd[87], 0x01) def test_move_home_does_not_affect_other_bytes(self): """Enabling move_home_first should only change one byte.""" - cmd_off = self.backend._build_wash_composite_command(PT96, move_home_first=False) - cmd_on = self.backend._build_wash_composite_command(PT96, move_home_first=True) + cmd_off = self.backend._plate_washing._build_wash_composite_command(PT96, move_home_first=False) + cmd_on = self.backend._plate_washing._build_wash_composite_command(PT96, move_home_first=True) # Only byte [87] should differ diffs = [i for i in range(102) if cmd_off[i] != cmd_on[i]] self.assertEqual(diffs, [87]) def test_move_home_with_shake_and_soak(self): """move_home_first should coexist with shake/soak parameters.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, move_home_first=True, shake_duration=15, shake_intensity="Fast", soak_duration=45 ) self.assertEqual(cmd[87], 0x01) # move_home @@ -359,7 +359,7 @@ def setUp(self): def test_secondary_aspirate_disabled_default(self): """Secondary aspirate offsets should use defaults when disabled.""" - cmd = self.backend._build_wash_composite_command(PT96, aspirate_z=40) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, aspirate_z=40) # Final aspirate: sec_z mirrors final_asp_z self.assertEqual(cmd[37], 0x28) # secondary Z = 40 self.assertEqual(cmd[38], 0x00) @@ -371,7 +371,7 @@ def test_secondary_aspirate_disabled_default(self): def test_secondary_aspirate_enabled(self): """When secondary_aspirate=True, primary aspirate gets secondary Z and mode enabled.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, aspirate_z=40, secondary_aspirate=True, secondary_z=100 ) # Final aspirate: secondary mode stays off by default @@ -394,7 +394,7 @@ def setUp(self): def test_pre_dispense_flow_rate_encoding(self): """pre_dispense_flow_rate should encode at correct positions.""" - cmd = self.backend._build_wash_composite_command(PT96, pre_dispense_flow_rate=7) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, pre_dispense_flow_rate=7) self.assertEqual(cmd[17], 7) # Dispense1 self.assertEqual(cmd[78], 7) # Dispense2 @@ -407,7 +407,7 @@ def setUp(self): def test_secondary_xy_default_zero(self): """Secondary X/Y should default to 0 and not affect baseline output.""" - cmd = self.backend._build_wash_composite_command(PT96) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96) # Final aspirate: always fixed 0 self.assertEqual(cmd[40], 0x00) # secondary X self.assertEqual(cmd[41], 0x00) # secondary Y @@ -417,7 +417,7 @@ def test_secondary_xy_default_zero(self): def test_secondary_xy_encoded_when_enabled(self): """Secondary X/Y should be encoded in primary aspirate when secondary_aspirate=True.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, secondary_aspirate=True, secondary_x=15, secondary_y=-10, secondary_z=50 ) # Final aspirate: always fixed 0 @@ -429,7 +429,7 @@ def test_secondary_xy_encoded_when_enabled(self): def test_secondary_xy_zero_when_disabled(self): """Secondary X/Y should be 0 when secondary_aspirate=False, even if values set.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, secondary_aspirate=False, secondary_x=15, secondary_y=-10 ) self.assertEqual(cmd[40], 0x00) # final aspirate (always fixed) @@ -446,7 +446,7 @@ def setUp(self): def test_bottom_wash_disabled_dispense1_mirrors_main(self): """When bottom_wash=False, Dispense1 should mirror main dispense volume/flow.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, dispense_volume=500.0, dispense_flow_rate=5 ) # Dispense1 @@ -460,7 +460,7 @@ def test_bottom_wash_disabled_dispense1_mirrors_main(self): def test_bottom_wash_enabled_dispense1_uses_bottom_params(self): """When bottom_wash=True, Dispense1 should use bottom wash volume/flow.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, dispense_volume=300.0, dispense_flow_rate=7, @@ -512,7 +512,7 @@ def setUp(self): def test_midcyc_disabled_dispense2_uses_main_pre_dispense(self): """When midcyc volume=0, Dispense2 pre-dispense mirrors main pre-dispense.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, pre_dispense_volume=100.0, pre_dispense_flow_rate=7 ) # Dispense1 @@ -526,7 +526,7 @@ def test_midcyc_disabled_dispense2_uses_main_pre_dispense(self): def test_midcyc_enabled_dispense2_uses_midcyc_values(self): """When midcyc volume>0, Dispense2 pre-dispense uses midcyc values.""" - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, pre_dispense_volume=100.0, pre_dispense_flow_rate=7, @@ -580,7 +580,7 @@ def test_baseline(self): "0000000000000000000000000300001d000000001d000000000000000000412c010700007900" "0000090000000000000000000000030000000000000000000000" ) - cmd = self.backend._build_wash_composite_command(PT96) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96) self.assertEqual(cmd, expected) def test_aspirate_xyz_capture(self): @@ -590,7 +590,7 @@ def test_aspirate_xyz_capture(self): "00000000000000000000000003050a1c000000001d000000000000000000412c010700007900" "0000090000000000000000000000030000000000000000000000" ) - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, aspirate_z=28, aspirate_x=5, aspirate_y=10 ) self.assertEqual(cmd, expected) @@ -602,7 +602,7 @@ def test_secondary_aspirate_capture(self): "0000000000000000000000000300001d000100001d000000000000000000412c010700007900" "0000090000000000000000000000030000000000000000000000" ) - cmd = self.backend._build_wash_composite_command(PT96, secondary_aspirate=True) + cmd = self.backend._plate_washing._build_wash_composite_command(PT96, secondary_aspirate=True) self.assertEqual(cmd, expected) def test_final_secondary_aspirate_capture(self): @@ -614,7 +614,7 @@ def test_final_secondary_aspirate_capture(self): "412c0107000079000000090000000000000000000000" "030000000000000000000000" ) - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, cycles=2, buffer="A", final_secondary_aspirate=True, final_secondary_z=40 ) self.assertEqual(cmd, expected) @@ -626,7 +626,7 @@ def test_bottom_wash_capture(self): "0000000000000000000000000300001d000000001d000000000000000000412c010700007900" "0000090000000000000000000000030000000000000000000000" ) - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, bottom_wash=True, bottom_wash_volume=200.0, bottom_wash_flow_rate=5 ) self.assertEqual(cmd, expected) @@ -638,7 +638,7 @@ def test_pre_dispense_between_cycles_capture(self): "0000000000000000000000000300001d000000001d000000000000000000412c010700007900" "3200090000000000000000000000030000000000000000000000" ) - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT96, pre_dispense_between_cycles_volume=50.0, pre_dispense_between_cycles_flow_rate=9 ) self.assertEqual(cmd, expected) @@ -652,7 +652,7 @@ def test_aspirate_delay_capture(self): "000000000000000000030000000000000000000000" ) expected = bytes.fromhex(capture_hex) - cmd = self.backend._build_wash_composite_command( + cmd = self.backend._plate_washing._build_wash_composite_command( PT384, cycles=3, sector_mask=0x0F, @@ -677,7 +677,7 @@ def test_p384_sector_plate_format_capture(self): "000000000000000000000003000016000000001600000000000000000041640007000078000000" "090000000000000000000000030000000000000000000000" ) - cmd0 = self.backend._build_wash_composite_command( + cmd0 = self.backend._plate_washing._build_wash_composite_command( PT384, cycles=1, sector_mask=0x0E, @@ -698,7 +698,7 @@ def test_p384_sector_plate_format_capture(self): "000000000000000000000003000016000000001600000000000000000041640007000078000000" "090000000000000000000000030000000000000000000000" ) - cmd2 = self.backend._build_wash_composite_command( + cmd2 = self.backend._plate_washing._build_wash_composite_command( PT384, cycles=1, sector_mask=0x0F, @@ -721,50 +721,50 @@ class TestWash384WellPlateSupport(unittest.TestCase): def test_384_well_plate_type_byte(self): """384-well backend should produce the correct plate type prefix.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT384) + cmd = backend._plate_washing._build_wash_composite_command(PT384) self.assertEqual(cmd[0], 0x01) def test_96_well_plate_type_byte(self): """96-well backend (default) should produce the correct plate type prefix.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96) + cmd = backend._plate_washing._build_wash_composite_command(PT96) self.assertEqual(cmd[0], 0x04) def test_wash_format_plate_default(self): """Default wash_format='Plate' should encode as 0.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96) + cmd = backend._plate_washing._build_wash_composite_command(PT96) self.assertEqual(cmd[3], 0x00) def test_wash_format_sector(self): """wash_format='Sector' should encode as 1.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96, wash_format="Sector") + cmd = backend._plate_washing._build_wash_composite_command(PT96, wash_format="Sector") self.assertEqual(cmd[3], 0x01) def test_cycles_at_byte6(self): """cycles should be encoded at the expected position.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96, cycles=5) + cmd = backend._plate_washing._build_wash_composite_command(PT96, cycles=5) self.assertEqual(cmd[6], 5) def test_cycles_default(self): """Default cycles=3 should be encoded correctly.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96) + cmd = backend._plate_washing._build_wash_composite_command(PT96) self.assertEqual(cmd[6], 3) def test_sector_mask_le_encoding(self): """Sector mask should be encoded as 16-bit LE.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96, sector_mask=0x0E) + cmd = backend._plate_washing._build_wash_composite_command(PT96, sector_mask=0x0E) self.assertEqual(cmd[4], 0x0E) self.assertEqual(cmd[5], 0x00) def test_384_well_full_combination(self): """384-well with Sector format and custom sector mask.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command( + cmd = backend._plate_washing._build_wash_composite_command( PT384, wash_format="Sector", cycles=1, sector_mask=0x0E, aspirate_travel_rate=3 ) self.assertEqual(cmd[0], 0x01) # plate type @@ -805,7 +805,7 @@ class TestWashPlateTypeDefaults(unittest.TestCase): def test_96_well_defaults(self): """96-well plate should use 96-well defaults (300uL, dispZ=121, aspZ=29).""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96) + cmd = backend._plate_washing._build_wash_composite_command(PT96) # dispense_volume=300 self.assertEqual(cmd[8], 0x2C) self.assertEqual(cmd[9], 0x01) @@ -822,7 +822,7 @@ def test_96_well_defaults(self): def test_384_well_defaults(self): """384-well plate should use 384-well defaults (100uL, dispZ=120, aspZ=22).""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT384) + cmd = backend._plate_washing._build_wash_composite_command(PT384) self.assertEqual(cmd[0], 0x01) # plate type # dispense_volume=100 self.assertEqual(cmd[8], 0x64) @@ -843,7 +843,7 @@ def test_384_well_defaults(self): def test_384_pcr_defaults(self): """384 PCR plate should use its specific defaults (100uL, dispZ=83, aspZ=2).""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT384PCR) + cmd = backend._plate_washing._build_wash_composite_command(PT384PCR) self.assertEqual(cmd[0], 0x02) # plate type self.assertEqual(cmd[8], 0x64) # vol=100 low self.assertEqual(cmd[13], 0x53) # dispense_z=83 @@ -853,7 +853,7 @@ def test_384_pcr_defaults(self): def test_1536_well_defaults(self): """1536-well plate should use its specific defaults.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT1536) + cmd = backend._plate_washing._build_wash_composite_command(PT1536) self.assertEqual(cmd[0], 0x00) # plate type # dispense_volume=100 self.assertEqual(cmd[8], 0x64) @@ -868,7 +868,7 @@ def test_1536_well_defaults(self): def test_1536_flange_defaults(self): """1536 flange plate should use its specific defaults.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT1536F) + cmd = backend._plate_washing._build_wash_composite_command(PT1536F) self.assertEqual(cmd[0], 0x0E) # plate type # dispense_volume=100 self.assertEqual(cmd[8], 0x64) @@ -883,7 +883,7 @@ def test_1536_flange_defaults(self): def test_explicit_values_override_plate_defaults(self): """Explicit parameter values should override plate-type defaults.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command( + cmd = backend._plate_washing._build_wash_composite_command( PT384, dispense_volume=500.0, dispense_z=200, aspirate_z=50, secondary_z=30 ) # dispense_volume=500 overrides 100 @@ -899,7 +899,7 @@ def test_explicit_values_override_plate_defaults(self): def test_secondary_z_independent_of_aspirate_z(self): """secondary_z default should be plate-type default, NOT user aspirate_z.""" backend = ExperimentalBioTekEL406Backend() - cmd = backend._build_wash_composite_command(PT96, aspirate_z=40) + cmd = backend._plate_washing._build_wash_composite_command(PT96, aspirate_z=40) # aspirate_z=40 (user override) self.assertEqual(cmd[53], 0x28) # aspirate_z = 40 # secondary_z should still be 29 (plate-type default), NOT 40 @@ -917,7 +917,7 @@ def test_all_plate_types_produce_102_bytes(self): "test_1536_flange": 0x0E, } for plate in plate_types: - cmd = backend._build_wash_composite_command(plate) + cmd = backend._plate_washing._build_wash_composite_command(plate) self.assertEqual(len(cmd), 102, f"Wrong length for {plate.name}") self.assertEqual(cmd[0], expected_prefixes[plate.name], f"Wrong prefix for {plate.name}") From 38547859d42218a6f4150747fb4d7226b86537f1 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 31 Mar 2026 21:24:50 -0700 Subject: [PATCH 28/69] WIP: Migrate Multidrop Combi to Device/Driver/CapabilityBackend architecture Co-Authored-By: Claude Opus 4.6 (1M context) --- pylabrobot/io/serial.py | 26 +- .../thermo_fisher/multidrop_combi/__init__.py | 21 ++ .../thermo_fisher/multidrop_combi/driver.py | 306 ++++++++++++++++++ .../thermo_fisher/multidrop_combi/enums.py | 25 ++ .../thermo_fisher/multidrop_combi/errors.py | 25 ++ .../thermo_fisher/multidrop_combi/helpers.py | 139 ++++++++ .../multidrop_combi/multidrop_combi.py | 35 ++ .../peristaltic_dispensing_backend.py | 303 +++++++++++++++++ .../multidrop_combi/tests/__init__.py | 0 .../multidrop_combi/tests/backend_tests.py | 79 +++++ .../multidrop_combi/tests/commands_tests.py | 291 +++++++++++++++++ .../tests/communication_tests.py | 182 +++++++++++ .../multidrop_combi/tests/helpers_tests.py | 209 ++++++++++++ 13 files changed, 1640 insertions(+), 1 deletion(-) create mode 100644 pylabrobot/thermo_fisher/multidrop_combi/__init__.py create mode 100644 pylabrobot/thermo_fisher/multidrop_combi/driver.py create mode 100644 pylabrobot/thermo_fisher/multidrop_combi/enums.py create mode 100644 pylabrobot/thermo_fisher/multidrop_combi/errors.py create mode 100644 pylabrobot/thermo_fisher/multidrop_combi/helpers.py create mode 100644 pylabrobot/thermo_fisher/multidrop_combi/multidrop_combi.py create mode 100644 pylabrobot/thermo_fisher/multidrop_combi/peristaltic_dispensing_backend.py create mode 100644 pylabrobot/thermo_fisher/multidrop_combi/tests/__init__.py create mode 100644 pylabrobot/thermo_fisher/multidrop_combi/tests/backend_tests.py create mode 100644 pylabrobot/thermo_fisher/multidrop_combi/tests/commands_tests.py create mode 100644 pylabrobot/thermo_fisher/multidrop_combi/tests/communication_tests.py create mode 100644 pylabrobot/thermo_fisher/multidrop_combi/tests/helpers_tests.py diff --git a/pylabrobot/io/serial.py b/pylabrobot/io/serial.py index 3b925bf2c35..78f6d980677 100644 --- a/pylabrobot/io/serial.py +++ b/pylabrobot/io/serial.py @@ -1,9 +1,10 @@ import asyncio +import contextlib import logging from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from io import IOBase -from typing import Optional, cast +from typing import Iterator, Optional, cast from pylabrobot.io.errors import ValidationError @@ -48,6 +49,7 @@ def __init__( timeout=1, rtscts: bool = False, dsrdtr: bool = False, + xonxoff: bool = False, ): self._human_readable_device_name = human_readable_device_name self._port = port @@ -63,6 +65,7 @@ def __init__( self.timeout = timeout self.rtscts = rtscts self.dsrdtr = dsrdtr + self.xonxoff = xonxoff # Instant parameter validation at init time if not self._port and not (self._vid and self._pid): @@ -76,6 +79,26 @@ def port(self) -> str: assert self._port is not None, "Port not set. Did you call setup()?" return self._port + def get_read_timeout(self) -> float: + """Get the current read timeout in seconds.""" + assert self._ser is not None, "Serial port not open. Did you call setup()?" + return self._ser.timeout + + def set_read_timeout(self, timeout: float) -> None: + """Set the read timeout in seconds.""" + assert self._ser is not None, "Serial port not open. Did you call setup()?" + self._ser.timeout = timeout + + @contextlib.contextmanager + def temporary_timeout(self, timeout: float) -> Iterator[None]: + """Context manager that temporarily changes the read timeout, then restores it.""" + original = self.get_read_timeout() + self.set_read_timeout(timeout) + try: + yield + finally: + self.set_read_timeout(original) + async def setup(self): """ Initialize the serial connection to the device. @@ -171,6 +194,7 @@ def _open_serial() -> serial.Serial: timeout=self.timeout, rtscts=self.rtscts, dsrdtr=self.dsrdtr, + xonxoff=self.xonxoff, ) try: diff --git a/pylabrobot/thermo_fisher/multidrop_combi/__init__.py b/pylabrobot/thermo_fisher/multidrop_combi/__init__.py new file mode 100644 index 00000000000..2d95c9294ba --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/__init__.py @@ -0,0 +1,21 @@ +from pylabrobot.thermo_fisher.multidrop_combi.driver import MultidropCombiDriver +from pylabrobot.thermo_fisher.multidrop_combi.enums import ( + CassetteType, + DispensingOrder, + EmptyMode, + PrimeMode, +) +from pylabrobot.thermo_fisher.multidrop_combi.errors import ( + MultidropCombiCommunicationError, + MultidropCombiError, + MultidropCombiInstrumentError, +) +from pylabrobot.thermo_fisher.multidrop_combi.helpers import ( + plate_to_pla_params, + plate_to_type_index, + plate_well_count, +) +from pylabrobot.thermo_fisher.multidrop_combi.multidrop_combi import MultidropCombi +from pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend import ( + MultidropCombiPeristalticDispensingBackend, +) diff --git a/pylabrobot/thermo_fisher/multidrop_combi/driver.py b/pylabrobot/thermo_fisher/multidrop_combi/driver.py new file mode 100644 index 00000000000..349a3c0b3ec --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/driver.py @@ -0,0 +1,306 @@ +"""Driver for the Thermo Scientific Multidrop Combi.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from typing import Optional + +from pylabrobot.device import Driver +from pylabrobot.io.serial import Serial +from pylabrobot.thermo_fisher.multidrop_combi.errors import ( + MultidropCombiCommunicationError, + MultidropCombiInstrumentError, +) + +logger = logging.getLogger(__name__) + +STATUS_OK = 0 + +ERROR_DESCRIPTIONS = { + 1: "Internal firmware error", + 2: "Unrecognized command", + 3: "Invalid command arguments", + 4: "Pump position error", + 5: "Plate X position error", + 6: "Plate Y position error", + 7: "Z position error", + 9: "Attempt to reset serial number", + 10: "Nonvolatile parameters lost", + 11: "No more memory for user data", + 12: "Pump or X motor was running", + 13: "X and Z positions conflict", + 14: "Cannot dispense: pump not primed", + 15: "Missing prime vessel", + 16: "Rotor shield not in place", + 17: "Dispense volume for all columns is 0", + 18: "Invalid plate type (bad plate index)", + 19: "Plate has not been defined", + 20: "Invalid rows in plate definition", + 21: "Invalid columns in plate definition", + 22: "Plate height is invalid", + 23: "Plate well volume invalid (too small or too big)", + 24: "Invalid cassette type (bad cassette index)", + 25: "Cassette not defined", + 26: "Invalid volume increment for cassette", + 27: "Invalid maximum volume for cassette", + 28: "Invalid minimum volume for cassette", + 29: "Invalid min/max pump speed for cassette", + 30: "Invalid pump rotor offset in cassette definition", + 32: "Dispensing volume not within cassette limits", + 33: "Invalid selector channel", + 34: "Invalid dispensing speed", + 35: "Dispensing height too low for plate", + 36: "Predispense volume not within cassette limits", + 37: "Invalid dispensing order", + 38: "Invalid X or Y dispensing offset", + 39: "RFID option not present", + 40: "RFID tag not present", + 41: "RFID tag data checksum incorrect", + 43: "Wrong cassette type", + 44: "Protocol/plate in use, cannot modify or delete", + 45: "Protocol/plate/cassette is read-only", +} + + +class MultidropCombiDriver(Driver): + """Driver for the Thermo Scientific Multidrop Combi reagent dispenser. + + Owns the serial connection and provides generic command transport, + device-level operations (send_abort_signal, restart, acknowledge_error), and queries. + + Communication is via RS232/USB serial at 9600 baud, 8N1. + + Args: + port: Serial port (e.g. "COM3", "/dev/ttyUSB0"). + timeout: Default serial read timeout in seconds. + """ + + def __init__( + self, + port: str, + timeout: float = 30.0, + ) -> None: + super().__init__() + self._port = port + self.timeout = timeout + self.io = Serial( + human_readable_device_name="Multidrop Combi", + port=port, + baudrate=9600, + bytesize=8, + parity="N", + stopbits=1, + timeout=timeout, + write_timeout=5, + xonxoff=True, + ) + self._command_lock: Optional[asyncio.Lock] = None + self._instrument_name: str = "" + self._firmware_version: str = "" + self._serial_number: str = "" + + async def setup(self) -> None: + self._command_lock = asyncio.Lock() + await self.io.setup() + await self._drain_stale_data() + + info = await self._enter_remote_mode() + self._instrument_name = info["instrument_name"] + self._firmware_version = info["firmware_version"] + self._serial_number = info["serial_number"] + + logger.info( + "Connected to %s (FW: %s, SN: %s)", + self._instrument_name, + self._firmware_version, + self._serial_number, + ) + + async def stop(self) -> None: + await self._exit_remote_mode() + await self.io.stop() + self._command_lock = None + + def serialize(self) -> dict: + return { + **super().serialize(), + "port": self._port, + "timeout": self.timeout, + } + + # --- Command transport --- + + async def send_command(self, cmd: str, timeout: float | None = None) -> list[str]: + """Send a command and return the data lines from the response. + + Args: + cmd: Command string (e.g. "DIS", "SPL 1", "SCV 0 500"). + timeout: Per-command read timeout in seconds. If None, uses default. + + Returns: + List of data lines (between echo and END terminator). + + Raises: + MultidropCombiCommunicationError: If not connected or communication fails. + MultidropCombiInstrumentError: If instrument returns non-zero status code. + """ + if self._command_lock is None: + raise MultidropCombiCommunicationError("Not connected to instrument", operation=cmd) + + cmd_code = cmd.split()[0] + + async with self._command_lock: + with self.io.temporary_timeout(timeout) if timeout is not None else contextlib.nullcontext(): + try: + logger.debug("TX: %r", cmd) + await self.io.write(f"{cmd}\r".encode("ascii")) + + lines: list[str] = [] + while True: + raw = await self.io.readline() + if not raw: + raise MultidropCombiCommunicationError( + f"Timeout reading response for {cmd_code}", operation=cmd + ) + line = raw.decode("ascii", errors="replace").strip() + logger.debug("RX: %r", line) + if not line: + continue + lines.append(line) + + if line.startswith(cmd_code) and " END " in line: + break + + # Parse status from END terminator + end_line = lines[-1] + parts = end_line.split() + status_code = int(parts[-1]) if parts[-1].isdigit() else -1 + + if status_code != STATUS_OK: + desc = ERROR_DESCRIPTIONS.get(status_code, "Unknown error") + logger.error( + "Command %s failed (status %d). RX lines: %s", cmd_code, status_code, lines + ) + raise MultidropCombiInstrumentError(status_code, desc) + + # Return data lines: skip echo (first) and END line (last) + data_lines = [] + for line in lines[:-1]: + line_upper = line.strip().upper() + if line_upper == cmd.strip().upper() or line_upper == cmd_code.upper(): + continue + data_lines.append(line) + + return data_lines + + except (MultidropCombiCommunicationError, MultidropCombiInstrumentError): + raise + except Exception as e: + raise MultidropCombiCommunicationError( + f"Communication error during {cmd_code}: {e}", + operation=cmd, + original_error=e, + ) from e + + # --- Device-level operations --- + + async def send_abort_signal(self) -> None: + """Send ESC character to abort the current operation.""" + await self.io.write(b"\x1b") + + async def restart(self) -> None: + """Restart the instrument (equivalent to power cycle).""" + await self.send_command("RST", timeout=10.0) + + async def acknowledge_error(self) -> None: + """Clear instrument error state.""" + await self.send_command("EAK", timeout=5.0) + + # --- Queries --- + + def get_version(self) -> dict: + """Return cached instrument identification info. + + Returns: + Dict with keys: instrument_name, firmware_version, serial_number. + """ + return { + "instrument_name": self._instrument_name, + "firmware_version": self._firmware_version, + "serial_number": self._serial_number, + } + + async def report_parameters(self) -> list[str]: + """Report instrument parameters (REP command).""" + return await self.send_command("REP", timeout=10.0) + + async def read_error_log(self) -> list[str]: + """Read the instrument error log (LOG command).""" + return await self.send_command("LOG", timeout=10.0) + + async def read_cassette_info(self) -> list[str]: + """Read RFID cassette info (RIR command).""" + return await self.send_command("RIR", timeout=5.0) + + # --- Internal helpers --- + + async def _drain_stale_data(self) -> None: + """Drain any stale data from the serial buffer.""" + await self.io.reset_input_buffer() + await self.io.reset_output_buffer() + + drained = 0 + with self.io.temporary_timeout(0.3): + while True: + stale = await self.io.readline() + if not stale: + break + drained += 1 + logger.debug("Drained stale data: %r", stale) + if drained: + logger.info("Drained %d stale lines from serial buffer", drained) + + async def _enter_remote_mode(self) -> dict: + """Send VER to enter remote control mode and get instrument info.""" + try: + lines = await self.send_command("VER", timeout=5.0) + except Exception as first_err: + logger.warning("VER failed (%s), sending EAK and retrying...", first_err) + try: + await self.send_command("EAK", timeout=5.0) + except Exception: + pass + try: + lines = await self.send_command("VER", timeout=5.0) + except Exception as e: + raise MultidropCombiCommunicationError( + f"VER command failed: {e}", operation="VER", original_error=e + ) from e + + info = { + "instrument_name": "Unknown", + "firmware_version": "Unknown", + "serial_number": "Unknown", + } + if lines: + raw = lines[0] + if raw.upper().startswith("VER "): + raw = raw[4:] + parts = raw.split() + if len(parts) > 0: + info["instrument_name"] = parts[0] + if len(parts) > 1: + info["firmware_version"] = parts[1] + if len(parts) > 2: + info["serial_number"] = parts[2] + + return info + + async def _exit_remote_mode(self) -> None: + """Send QIT to exit remote control mode.""" + try: + await self.send_command("QIT", timeout=5.0) + except Exception: + pass diff --git a/pylabrobot/thermo_fisher/multidrop_combi/enums.py b/pylabrobot/thermo_fisher/multidrop_combi/enums.py new file mode 100644 index 00000000000..af964de8123 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/enums.py @@ -0,0 +1,25 @@ +import enum + + +class CassetteType(enum.IntEnum): + STANDARD = 0 + SMALL = 1 + USER_DEFINED_1 = 2 + USER_DEFINED_2 = 3 + + +class DispensingOrder(enum.IntEnum): + ROW_WISE = 0 + COLUMN_WISE = 1 + + +class PrimeMode(enum.IntEnum): + STANDARD = 0 + CONTINUOUS = 1 + STOP_CONTINUOUS = 2 + CALIBRATION = 3 + + +class EmptyMode(enum.IntEnum): + STANDARD = 0 + CONTINUOUS = 1 diff --git a/pylabrobot/thermo_fisher/multidrop_combi/errors.py b/pylabrobot/thermo_fisher/multidrop_combi/errors.py new file mode 100644 index 00000000000..0284adaf58a --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/errors.py @@ -0,0 +1,25 @@ +from __future__ import annotations + + +class MultidropCombiError(Exception): + """Base exception for Multidrop Combi errors.""" + + +class MultidropCombiCommunicationError(MultidropCombiError): + """Serial communication failure (port not found, timeout, connection lost).""" + + def __init__( + self, message: str, operation: str = "", original_error: Exception | None = None + ) -> None: + self.operation = operation + self.original_error = original_error + super().__init__(message) + + +class MultidropCombiInstrumentError(MultidropCombiError): + """Instrument returned a non-zero status code.""" + + def __init__(self, status_code: int, description: str) -> None: + self.status_code = status_code + self.description = description + super().__init__(f"Instrument error (status {status_code}): {description}") diff --git a/pylabrobot/thermo_fisher/multidrop_combi/helpers.py b/pylabrobot/thermo_fisher/multidrop_combi/helpers.py new file mode 100644 index 00000000000..1ed4e072739 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/helpers.py @@ -0,0 +1,139 @@ +"""Plate type helpers for the Multidrop Combi. + +Maps PyLabRobot Plate resources to Multidrop Combi plate type indices and +PLA (remote plate definition) command parameters. +""" + +from __future__ import annotations + +from pylabrobot.resources import Plate + +# Multidrop Combi factory plate type definitions (from manual Table 3-3). +# Type index → (well_count, max_plate_height_mm) +# Heights are upper bounds for selecting the best-fit factory type. +_FACTORY_96_WELL_TYPES = [ + # (type_index, max_height_mm) + (0, 18.0), # Type 0: 96-well, 15mm + (1, 30.0), # Type 1: 96-well, 22mm + (2, 55.0), # Type 2: 96-well, 44mm +] + +_FACTORY_384_WELL_TYPES = [ + (3, 8.5), # Type 3: 384-well, 7.5mm + (4, 12.0), # Type 4: 384-well, 10mm + (5, 18.0), # Type 5: 384-well, 15mm + (6, 30.0), # Type 6: 384-well, 22mm + (7, 55.0), # Type 7: 384-well, 44mm +] + +_FACTORY_1536_WELL_TYPES = [ + (8, 7.0), # Type 8: 1536-well, 5mm + (9, 55.0), # Type 9: 1536-well, 10.5mm +] + +# Hardware limits +MAX_COLUMNS = 48 +MAX_ROWS = 32 +MIN_HEIGHT_HUNDREDTHS_MM = 500 # 5mm +MAX_HEIGHT_HUNDREDTHS_MM = 5500 # 55mm +MAX_VOLUME_TENTHS_UL = 25000 # 2500 uL + + +def plate_to_type_index(plate: Plate) -> int: + """Map a PLR Plate to the best-fit Multidrop Combi factory plate type index. + + Selects the factory type based on well count and plate height (size_z). + The smallest factory type whose height threshold accommodates the plate is chosen. + + Args: + plate: A PyLabRobot Plate resource. + + Returns: + Factory plate type index (0-9). + + Raises: + ValueError: If the plate well count is not 96, 384, or 1536, or if the + plate height exceeds all factory type thresholds. + """ + wells = plate.num_items + height_mm = plate.get_size_z() + + if wells == 96: + type_list = _FACTORY_96_WELL_TYPES + elif wells == 384: + type_list = _FACTORY_384_WELL_TYPES + elif wells == 1536: + type_list = _FACTORY_1536_WELL_TYPES + else: + raise ValueError( + f"Unsupported well count: {wells}. " + "Multidrop factory types support 96, 384, or 1536 wells. " + "Use plate_to_pla_params() for custom plate definitions." + ) + + for type_index, max_height in type_list: + if height_mm <= max_height: + return type_index + + raise ValueError( + f"Plate height {height_mm}mm exceeds all factory type thresholds for {wells}-well plates." + ) + + +def plate_to_pla_params(plate: Plate) -> dict: + """Convert a PLR Plate to Multidrop Combi PLA command parameters. + + Use this for plates that don't match factory types (types 0-9), or when you + want precise control over the plate definition sent to the instrument. + The returned dict can be passed directly to ``backend.define_plate(**params)``. + + Args: + plate: A PyLabRobot Plate resource. + + Returns: + Dict with keys matching ``define_plate()`` parameters: + column_positions, row_positions, rows, columns, height, max_volume. + + Raises: + ValueError: If any parameter exceeds Multidrop hardware limits. + """ + columns = plate.num_items_x + rows = plate.num_items_y + height_hundredths = round(plate.get_size_z() * 100) + + # Get max_volume from first well (in uL) + first_well = plate.get_well("A1") + well_max_volume_ul = first_well.max_volume + + # Validate against hardware limits + if columns > MAX_COLUMNS: + raise ValueError(f"Plate has {columns} columns, but Multidrop supports at most {MAX_COLUMNS}.") + if rows > MAX_ROWS: + raise ValueError(f"Plate has {rows} rows, but Multidrop supports at most {MAX_ROWS}.") + if height_hundredths < MIN_HEIGHT_HUNDREDTHS_MM: + raise ValueError( + f"Plate height {plate.get_size_z()}mm is below minimum {MIN_HEIGHT_HUNDREDTHS_MM / 100}mm." + ) + if height_hundredths > MAX_HEIGHT_HUNDREDTHS_MM: + raise ValueError( + f"Plate height {plate.get_size_z()}mm exceeds maximum {MAX_HEIGHT_HUNDREDTHS_MM / 100}mm." + ) + if well_max_volume_ul > MAX_VOLUME_TENTHS_UL / 10: + raise ValueError( + f"Well max volume {well_max_volume_ul} uL exceeds Multidrop limit of " + f"{MAX_VOLUME_TENTHS_UL / 10} uL." + ) + + return { + "column_positions": columns, + "row_positions": rows, + "rows": rows, + "columns": columns, + "height": height_hundredths, + "max_volume": well_max_volume_ul, + } + + +def plate_well_count(plate: Plate) -> int: + """Return the total well count for a plate.""" + return plate.num_items diff --git a/pylabrobot/thermo_fisher/multidrop_combi/multidrop_combi.py b/pylabrobot/thermo_fisher/multidrop_combi/multidrop_combi.py new file mode 100644 index 00000000000..f3e8284b5dd --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/multidrop_combi.py @@ -0,0 +1,35 @@ +"""Thermo Scientific Multidrop Combi device.""" + +from __future__ import annotations + +from pylabrobot.capabilities.bulk_dispensers.peristaltic import PeristalticDispensing +from pylabrobot.device import Device +from pylabrobot.thermo_fisher.multidrop_combi.driver import MultidropCombiDriver +from pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend import ( + MultidropCombiPeristalticDispensingBackend, +) + + +class MultidropCombi(Device): + """Thermo Scientific Multidrop Combi reagent dispenser. + + Args: + port: Serial port (e.g. "COM3", "/dev/ttyUSB0"). + timeout: Default serial read timeout in seconds. + """ + + def __init__( + self, + port: str, + timeout: float = 30.0, + *, + driver: MultidropCombiDriver | None = None, + ) -> None: + if driver is None: + driver = MultidropCombiDriver(port=port, timeout=timeout) + super().__init__(driver=driver) + self._driver: MultidropCombiDriver = driver + self.peristaltic = PeristalticDispensing( + backend=MultidropCombiPeristalticDispensingBackend(driver) + ) + self._capabilities = [self.peristaltic] diff --git a/pylabrobot/thermo_fisher/multidrop_combi/peristaltic_dispensing_backend.py b/pylabrobot/thermo_fisher/multidrop_combi/peristaltic_dispensing_backend.py new file mode 100644 index 00000000000..569f321b91e --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/peristaltic_dispensing_backend.py @@ -0,0 +1,303 @@ +"""Peristaltic dispensing capability backend for the Multidrop Combi. + +All volume parameters at the public interface are in microliters (float). +Internally, volumes are converted to the instrument's native 1/10 uL units. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Optional + +from pylabrobot.capabilities.bulk_dispensers.peristaltic import PeristalticDispensingBackend +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.thermo_fisher.multidrop_combi.driver import MultidropCombiDriver +from pylabrobot.thermo_fisher.multidrop_combi.enums import ( + DispensingOrder, + EmptyMode, + PrimeMode, +) +from pylabrobot.resources import Plate + + +def _ul_to_tenths(volume_ul: float) -> int: + """Convert microliters to 1/10 uL integer.""" + return round(volume_ul * 10) + + +class MultidropCombiPeristalticDispensingBackend(PeristalticDispensingBackend): + """Translates PeristalticDispensingBackend operations into Multidrop Combi commands.""" + + @dataclass + class DispenseParams(BackendParams): + """Parameters for the Multidrop Combi dispense command. + + Args: + plate_type: Plate type index (0-29). If None, uses current setting. + dispensing_height: Height in 1/100 mm (500-5500). If None, uses current setting. + pump_speed: Speed percentage (1-100). If None, uses current setting. + dispensing_order: Row-wise or column-wise. If None, uses current setting. + """ + plate_type: Optional[int] = None + dispensing_height: Optional[int] = None + pump_speed: Optional[int] = None + dispensing_order: Optional[DispensingOrder] = None + + @dataclass + class PrimeParams(BackendParams): + """Parameters for the Multidrop Combi prime command. + + Args: + mode: Prime mode (standard, continuous, stop continuous, calibration). + """ + mode: PrimeMode = PrimeMode.STANDARD + + @dataclass + class PurgeParams(BackendParams): + """Parameters for the Multidrop Combi purge (empty) command. + + Args: + mode: Empty mode (standard or continuous). + """ + mode: EmptyMode = EmptyMode.STANDARD + + def __init__(self, driver: MultidropCombiDriver): + super().__init__() + self._driver = driver + + async def _on_setup(self): + """Clear any pending instrument errors after the driver connects.""" + try: + await self._driver.acknowledge_error() + except Exception: + pass + + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + """Dispense liquid to the plate (DIS command). + + Args: + plate: Target plate. + volumes: Mapping of 1-indexed column number to volume in uL. + backend_params: A DispenseParams instance with device-specific settings. + """ + if not isinstance(backend_params, self.DispenseParams): + backend_params = self.DispenseParams() + + if backend_params.plate_type is not None: + await self._set_plate_type(backend_params.plate_type) + for col, vol in volumes.items(): + await self._set_column_volume(col, vol) + if backend_params.dispensing_height is not None: + await self._set_dispensing_height(backend_params.dispensing_height) + if backend_params.pump_speed is not None: + await self._set_pump_speed(backend_params.pump_speed) + if backend_params.dispensing_order is not None: + await self._set_dispensing_order(backend_params.dispensing_order) + + await self._driver.send_command("DIS", timeout=120.0) + + async def prime( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Prime dispenser hoses (PRI command). + + The Multidrop Combi only supports volume-based priming, not duration. + + Args: + plate: Target plate. + volume: Prime volume in microliters. + duration: Not supported — raises ValueError if provided. + backend_params: A PrimeParams instance with device-specific settings. + """ + if duration is not None: + raise ValueError("Multidrop Combi does not support duration-based priming. Use volume.") + if volume is None: + raise ValueError("volume is required for Multidrop Combi priming.") + + if not isinstance(backend_params, self.PrimeParams): + backend_params = self.PrimeParams() + + vol_tenths = _ul_to_tenths(volume) + if vol_tenths < 10 or vol_tenths > 100000: + raise ValueError(f"Prime volume must be 1-10000 uL, got {volume} uL") + cmd = f"PRI {vol_tenths}" + if backend_params.mode != PrimeMode.STANDARD: + cmd += f" {backend_params.mode.value}" + await self._driver.send_command(cmd, timeout=60.0 + volume / 100.0) + + async def purge( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + """Purge (empty) dispenser hoses (EMP command). + + The Multidrop Combi only supports volume-based purging, not duration. + + Args: + plate: Target plate. + volume: Purge volume in microliters. + duration: Not supported — raises ValueError if provided. + backend_params: A PurgeParams instance with device-specific settings. + """ + if duration is not None: + raise ValueError("Multidrop Combi does not support duration-based purging. Use volume.") + if volume is None: + raise ValueError("volume is required for Multidrop Combi purging.") + + if not isinstance(backend_params, self.PurgeParams): + backend_params = self.PurgeParams() + + vol_tenths = _ul_to_tenths(volume) + if vol_tenths < 10 or vol_tenths > 100000: + raise ValueError(f"Purge volume must be 1-10000 uL, got {volume} uL") + cmd = f"EMP {vol_tenths}" + if backend_params.mode != EmptyMode.STANDARD: + cmd += f" {backend_params.mode.value}" + await self._driver.send_command(cmd, timeout=60.0 + volume / 100.0) + + # --- Multidrop-specific methods --- + + async def shake(self, time: float, distance: int, speed: int) -> None: + """Shake the plate. + + Args: + time: Duration in seconds. + distance: Shake distance in mm (1-5). + speed: Shake frequency in Hz (1-20). + """ + if not 1 <= distance <= 5: + raise ValueError(f"Shake distance must be 1-5 mm, got {distance}") + if not 1 <= speed <= 20: + raise ValueError(f"Shake speed must be 1-20 Hz, got {speed}") + time_hundredths = round(time * 100) + if time_hundredths < 1: + raise ValueError(f"Shake time must be > 0, got {time}s") + await self._driver.send_command( + f"SHA {time_hundredths} {distance} {speed}", timeout=120.0 + time + ) + + async def move_plate_out(self) -> None: + """Move plate carrier to loading position (POU command).""" + await self._driver.send_command("POU", timeout=10.0) + + async def set_cassette_type(self, cassette_type: int) -> None: + """Set cassette type. + + Args: + cassette_type: Cassette type (0=Standard, 1=Small, 2-3=User-defined). + """ + if not 0 <= cassette_type <= 3: + raise ValueError(f"Cassette type must be 0-3, got {cassette_type}") + await self._driver.send_command(f"SCT {cassette_type}", timeout=5.0) + + async def abort(self) -> None: + """Abort the current operation.""" + await self._driver.send_abort_signal() + + async def set_dispense_offset(self, x_offset: int, y_offset: int) -> None: + """Set X/Y dispense offset. + + Args: + x_offset: X offset in 1/100 mm (+-300). + y_offset: Y offset in 1/100 mm (+-300). + """ + if not -300 <= x_offset <= 300: + raise ValueError(f"X offset must be +-300, got {x_offset}") + if not -300 <= y_offset <= 300: + raise ValueError(f"Y offset must be +-300, got {y_offset}") + await self._driver.send_command(f"SOF {x_offset} {y_offset}", timeout=5.0) + + async def set_predispense_volume(self, volume: float) -> None: + """Set predispense volume. + + Args: + volume: Predispense volume in microliters. + """ + vol_tenths = _ul_to_tenths(volume) + if vol_tenths < 10 or vol_tenths > 100000: + raise ValueError(f"Predispense volume must be 1-10000 uL, got {volume} uL") + await self._driver.send_command(f"SPV {vol_tenths}", timeout=5.0) + + async def define_plate( + self, + column_positions: int, + row_positions: int, + rows: int, + columns: int, + height: int, + max_volume: float, + x_offset: int = 0, + y_offset: int = 0, + ) -> None: + """Define a remote plate (PLA command). + + Args: + column_positions: Number of column positions. + row_positions: Number of row positions. + rows: Number of rows. + columns: Number of columns. + height: Plate height in 1/100 mm. + max_volume: Maximum well volume in microliters. + x_offset: X offset in 1/100 mm. + y_offset: Y offset in 1/100 mm. + """ + max_volume_tenths = _ul_to_tenths(max_volume) + await self._driver.send_command( + f"PLA {column_positions} {row_positions} {rows} {columns} " + f"{height} {max_volume_tenths} {x_offset} {y_offset}", + timeout=5.0, + ) + + async def start_protocol( + self, plate_type: int | None = None, protocol_name: str | None = None + ) -> None: + """Start a protocol from instrument memory (BGN command). + + Args: + plate_type: Optional plate type override. + protocol_name: Optional protocol name. + """ + cmd = "BGN" + if plate_type is not None: + cmd += f" {plate_type}" + if protocol_name is not None: + cmd += f" {protocol_name}" + await self._driver.send_command(cmd, timeout=120.0) + + # --- Private configuration methods (called by dispense) --- + + async def _set_plate_type(self, plate_type: int) -> None: + if not 0 <= plate_type <= 29: + raise ValueError(f"Plate type must be 0-29, got {plate_type}") + await self._driver.send_command(f"SPL {plate_type}", timeout=5.0) + + async def _set_column_volume(self, column: int, volume: float) -> None: + if not 1 <= column <= 48: + raise ValueError(f"Column must be 1-48, got {column}") + vol_tenths = _ul_to_tenths(volume) + await self._driver.send_command(f"SCV {column} {vol_tenths}", timeout=5.0) + + async def _set_dispensing_height(self, height: int) -> None: + if not 500 <= height <= 5500: + raise ValueError(f"Dispensing height must be 500-5500, got {height}") + await self._driver.send_command(f"SDH {height}", timeout=5.0) + + async def _set_pump_speed(self, speed: int) -> None: + if not 1 <= speed <= 100: + raise ValueError(f"Pump speed must be 1-100, got {speed}") + await self._driver.send_command(f"SPS {speed}", timeout=5.0) + + async def _set_dispensing_order(self, order: DispensingOrder) -> None: + await self._driver.send_command(f"SDO {int(order)}", timeout=5.0) diff --git a/pylabrobot/thermo_fisher/multidrop_combi/tests/__init__.py b/pylabrobot/thermo_fisher/multidrop_combi/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pylabrobot/thermo_fisher/multidrop_combi/tests/backend_tests.py b/pylabrobot/thermo_fisher/multidrop_combi/tests/backend_tests.py new file mode 100644 index 00000000000..d125310da6c --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/tests/backend_tests.py @@ -0,0 +1,79 @@ +import contextlib +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from pylabrobot.thermo_fisher.multidrop_combi.driver import MultidropCombiDriver + + +class DriverSerializationTests(unittest.TestCase): + def test_serialize(self): + driver = MultidropCombiDriver(port="COM3", timeout=15.0) + data = driver.serialize() + self.assertEqual(data["type"], "MultidropCombiDriver") + self.assertEqual(data["port"], "COM3") + self.assertEqual(data["timeout"], 15.0) + + def test_serialize_defaults(self): + driver = MultidropCombiDriver(port="/dev/ttyUSB0") + data = driver.serialize() + self.assertEqual(data["port"], "/dev/ttyUSB0") + self.assertEqual(data["timeout"], 30.0) + + +class DriverLifecycleTests(unittest.IsolatedAsyncioTestCase): + @patch("pylabrobot.thermo_fisher.multidrop_combi.driver.Serial") + async def test_setup_and_stop(self, MockSerial): + mock_serial = MagicMock() + mock_serial.setup = AsyncMock() + mock_serial.stop = AsyncMock() + mock_serial.write = AsyncMock() + mock_serial.readline = AsyncMock() + mock_serial.reset_input_buffer = AsyncMock() + mock_serial.reset_output_buffer = AsyncMock() + MockSerial.return_value = mock_serial + + # Mock timeout API + _timeout = 30.0 + + def get_read_timeout(): + return _timeout + + def set_read_timeout(t): + nonlocal _timeout + _timeout = t + + @contextlib.contextmanager + def temporary_timeout(t): + original = get_read_timeout() + set_read_timeout(t) + try: + yield + finally: + set_read_timeout(original) + + mock_serial.get_read_timeout = get_read_timeout + mock_serial.set_read_timeout = set_read_timeout + mock_serial.temporary_timeout = temporary_timeout + + # Setup readline responses: drain (empty), VER + mock_serial.readline.side_effect = [ + b"", # drain - empty + b"VER\r\n", # VER echo + b"MultidropCombi 2.00.29 836-4191\r\n", # VER data + b"VER END 0\r\n", # VER end + ] + + driver = MultidropCombiDriver(port="COM3") + await driver.setup() + + self.assertEqual(driver._instrument_name, "MultidropCombi") + self.assertEqual(driver._firmware_version, "2.00.29") + self.assertEqual(driver._serial_number, "836-4191") + + # Reset readline for QIT during stop + mock_serial.readline.side_effect = [ + b"QIT\r\n", + b"QIT END 0\r\n", + ] + await driver.stop() + mock_serial.stop.assert_awaited_once() diff --git a/pylabrobot/thermo_fisher/multidrop_combi/tests/commands_tests.py b/pylabrobot/thermo_fisher/multidrop_combi/tests/commands_tests.py new file mode 100644 index 00000000000..8e185b6db64 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/tests/commands_tests.py @@ -0,0 +1,291 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend import ( + MultidropCombiPeristalticDispensingBackend, + _ul_to_tenths, +) +from pylabrobot.thermo_fisher.multidrop_combi.enums import DispensingOrder, PrimeMode, EmptyMode +from pylabrobot.resources import Plate, Well, create_ordered_items_2d +from pylabrobot.resources.well import CrossSectionType, WellBottomType + + +def _make_backend() -> MultidropCombiPeristalticDispensingBackend: + """Create a backend with a mock driver.""" + driver = MagicMock() + driver.send_command = AsyncMock(return_value=[]) + driver.send_abort_signal = AsyncMock() + driver.acknowledge_error = AsyncMock() + backend = MultidropCombiPeristalticDispensingBackend(driver=driver) + return backend + + +def _make_plate() -> Plate: + return Plate( + name="test_plate", + size_x=127.76, + size_y=85.48, + size_z=14.2, + model="test", + ordered_items=create_ordered_items_2d( + Well, + num_items_x=12, + num_items_y=8, + dx=10.0, + dy=7.0, + dz=1.0, + item_dx=9.0, + item_dy=9.0, + size_x=6.0, + size_y=6.0, + size_z=10.67, + bottom_type=WellBottomType.FLAT, + cross_section_type=CrossSectionType.CIRCLE, + max_volume=360.0, + ), + ) + + +class VolumeConversionTests(unittest.TestCase): + def test_ul_to_tenths(self): + self.assertEqual(_ul_to_tenths(1.0), 10) + self.assertEqual(_ul_to_tenths(50.0), 500) + self.assertEqual(_ul_to_tenths(0.1), 1) + self.assertEqual(_ul_to_tenths(10000.0), 100000) + + def test_ul_to_tenths_rounding(self): + self.assertEqual(_ul_to_tenths(1.06), 11) + self.assertEqual(_ul_to_tenths(1.04), 10) + + +class DispenseTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = _make_backend() + self.plate = _make_plate() + + async def test_dispense_bare(self): + await self.backend.dispense(plate=self.plate, volumes={1: 10.0}) + calls = [c[0][0] for c in self.backend._driver.send_command.call_args_list] + self.assertEqual(calls, ["SCV 1 100", "DIS"]) + + async def test_dispense_per_column(self): + await self.backend.dispense(plate=self.plate, volumes={1: 10.0, 3: 20.0}) + calls = [c[0][0] for c in self.backend._driver.send_command.call_args_list] + self.assertEqual(calls, ["SCV 1 100", "SCV 3 200", "DIS"]) + + async def test_dispense_with_all_params(self): + params = MultidropCombiPeristalticDispensingBackend.DispenseParams( + plate_type=3, dispensing_height=2500, pump_speed=75, + dispensing_order=DispensingOrder.COLUMN_WISE, + ) + await self.backend.dispense(plate=self.plate, volumes={1: 50.0}, backend_params=params) + calls = [c[0][0] for c in self.backend._driver.send_command.call_args_list] + self.assertEqual(calls, ["SPL 3", "SCV 1 500", "SDH 2500", "SPS 75", "SDO 1", "DIS"]) + + async def test_dispense_order(self): + params = MultidropCombiPeristalticDispensingBackend.DispenseParams( + dispensing_order=DispensingOrder.ROW_WISE, + ) + await self.backend.dispense(plate=self.plate, volumes={1: 10.0}, backend_params=params) + calls = [c[0][0] for c in self.backend._driver.send_command.call_args_list] + self.assertEqual(calls, ["SCV 1 100", "SDO 0", "DIS"]) + + +class PrimeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = _make_backend() + self.plate = _make_plate() + + async def test_prime_standard(self): + await self.backend.prime(plate=self.plate, volume=50.0) + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "PRI 500") + + async def test_prime_continuous(self): + params = MultidropCombiPeristalticDispensingBackend.PrimeParams(mode=PrimeMode.CONTINUOUS) + await self.backend.prime(plate=self.plate, volume=50.0, backend_params=params) + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "PRI 500 1") + + async def test_prime_duration_not_supported(self): + with self.assertRaises(ValueError): + await self.backend.prime(plate=self.plate, duration=10) + + async def test_prime_volume_required(self): + with self.assertRaises(ValueError): + await self.backend.prime(plate=self.plate) + + +class PurgeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = _make_backend() + self.plate = _make_plate() + + async def test_purge_standard(self): + await self.backend.purge(plate=self.plate, volume=100.0) + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "EMP 1000") + + async def test_purge_continuous(self): + params = MultidropCombiPeristalticDispensingBackend.PurgeParams(mode=EmptyMode.CONTINUOUS) + await self.backend.purge(plate=self.plate, volume=100.0, backend_params=params) + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "EMP 1000 1") + + async def test_purge_duration_not_supported(self): + with self.assertRaises(ValueError): + await self.backend.purge(plate=self.plate, duration=10) + + async def test_purge_volume_required(self): + with self.assertRaises(ValueError): + await self.backend.purge(plate=self.plate) + + +class ShakeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = _make_backend() + + async def test_shake(self): + await self.backend.shake(time=5.0, distance=3, speed=10) + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "SHA 500 3 10") + + +class DeviceSpecificTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = _make_backend() + + async def test_move_plate_out(self): + await self.backend.move_plate_out() + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "POU") + + async def test_set_cassette_type(self): + await self.backend.set_cassette_type(cassette_type=1) + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "SCT 1") + + async def test_abort(self): + await self.backend.abort() + self.backend._driver.send_abort_signal.assert_awaited_once() + + async def test_set_dispense_offset(self): + await self.backend.set_dispense_offset(x_offset=100, y_offset=-50) + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "SOF 100 -50") + + async def test_set_predispense_volume(self): + await self.backend.set_predispense_volume(volume=10.0) + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "SPV 100") + + async def test_define_plate(self): + await self.backend.define_plate( + column_positions=12, row_positions=8, rows=8, columns=12, + height=1420, max_volume=360.0, + ) + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "PLA 12 8 8 12 1420 3600 0 0") + + async def test_define_plate_with_offsets(self): + await self.backend.define_plate( + column_positions=12, row_positions=8, rows=8, columns=12, + height=1420, max_volume=360.0, x_offset=100, y_offset=-50, + ) + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "PLA 12 8 8 12 1420 3600 100 -50") + + async def test_start_protocol_bare(self): + await self.backend.start_protocol() + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "BGN") + + async def test_start_protocol_with_plate_type(self): + await self.backend.start_protocol(plate_type=3) + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "BGN 3") + + async def test_start_protocol_with_name(self): + await self.backend.start_protocol(plate_type=3, protocol_name="MyProtocol") + args = self.backend._driver.send_command.call_args + self.assertEqual(args[0][0], "BGN 3 MyProtocol") + + +class ParameterValidationTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.backend = _make_backend() + self.plate = _make_plate() + + async def test_prime_volume_too_low(self): + with self.assertRaises(ValueError): + await self.backend.prime(plate=self.plate, volume=0.0) + + async def test_prime_volume_too_high(self): + with self.assertRaises(ValueError): + await self.backend.prime(plate=self.plate, volume=20000.0) + + async def test_purge_volume_too_low(self): + with self.assertRaises(ValueError): + await self.backend.purge(plate=self.plate, volume=0.0) + + async def test_shake_distance_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.shake(time=5.0, distance=0, speed=10) + with self.assertRaises(ValueError): + await self.backend.shake(time=5.0, distance=6, speed=10) + + async def test_shake_speed_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.shake(time=5.0, distance=3, speed=0) + with self.assertRaises(ValueError): + await self.backend.shake(time=5.0, distance=3, speed=21) + + async def test_dispense_plate_type_out_of_range(self): + params = MultidropCombiPeristalticDispensingBackend.DispenseParams(plate_type=-1) + with self.assertRaises(ValueError): + await self.backend.dispense(plate=self.plate, volumes={1: 10.0}, backend_params=params) + params = MultidropCombiPeristalticDispensingBackend.DispenseParams(plate_type=30) + with self.assertRaises(ValueError): + await self.backend.dispense(plate=self.plate, volumes={1: 10.0}, backend_params=params) + + async def test_dispense_column_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.dispense(plate=self.plate, volumes={0: 10.0}) + with self.assertRaises(ValueError): + await self.backend.dispense(plate=self.plate, volumes={49: 10.0}) + + async def test_dispense_height_out_of_range(self): + params = MultidropCombiPeristalticDispensingBackend.DispenseParams(dispensing_height=499) + with self.assertRaises(ValueError): + await self.backend.dispense(plate=self.plate, volumes={1: 10.0}, backend_params=params) + + async def test_dispense_pump_speed_out_of_range(self): + params = MultidropCombiPeristalticDispensingBackend.DispenseParams(pump_speed=0) + with self.assertRaises(ValueError): + await self.backend.dispense(plate=self.plate, volumes={1: 10.0}, backend_params=params) + + async def test_cassette_type_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.set_cassette_type(cassette_type=4) + + async def test_dispense_offset_out_of_range(self): + with self.assertRaises(ValueError): + await self.backend.set_dispense_offset(x_offset=301, y_offset=0) + with self.assertRaises(ValueError): + await self.backend.set_dispense_offset(x_offset=0, y_offset=-301) + + async def test_purge_volume_too_high(self): + with self.assertRaises(ValueError): + await self.backend.purge(plate=self.plate, volume=20000.0) + + async def test_predispense_volume_too_low(self): + with self.assertRaises(ValueError): + await self.backend.set_predispense_volume(volume=0.0) + + async def test_predispense_volume_too_high(self): + with self.assertRaises(ValueError): + await self.backend.set_predispense_volume(volume=20000.0) + + async def test_on_setup_calls_acknowledge_error(self): + await self.backend._on_setup() + self.backend._driver.acknowledge_error.assert_awaited_once() diff --git a/pylabrobot/thermo_fisher/multidrop_combi/tests/communication_tests.py b/pylabrobot/thermo_fisher/multidrop_combi/tests/communication_tests.py new file mode 100644 index 00000000000..935312a6df2 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/tests/communication_tests.py @@ -0,0 +1,182 @@ +import asyncio +import contextlib +import unittest +from unittest.mock import AsyncMock, MagicMock + +from pylabrobot.thermo_fisher.multidrop_combi.driver import MultidropCombiDriver +from pylabrobot.thermo_fisher.multidrop_combi.errors import ( + MultidropCombiCommunicationError, + MultidropCombiInstrumentError, +) + + +def _make_driver() -> MultidropCombiDriver: + """Create a driver with a mock Serial for testing.""" + driver = MultidropCombiDriver(port="COM3") + mock_io = MagicMock() + mock_io.write = AsyncMock() + mock_io.readline = AsyncMock() + mock_io.reset_input_buffer = AsyncMock() + mock_io.reset_output_buffer = AsyncMock() + + # Mock the timeout API + _timeout = 30.0 + + def get_read_timeout(): + return _timeout + + def set_read_timeout(t): + nonlocal _timeout + _timeout = t + + mock_io.get_read_timeout = get_read_timeout + mock_io.set_read_timeout = set_read_timeout + mock_io.temporary_timeout = lambda t: contextlib.contextmanager( + lambda: (mock_io.set_read_timeout(t), (yield), mock_io.set_read_timeout(_timeout)) + )() + + # Use the real temporary_timeout from Serial for correct behavior + @contextlib.contextmanager + def _temporary_timeout(timeout): + original = mock_io.get_read_timeout() + mock_io.set_read_timeout(timeout) + try: + yield + finally: + mock_io.set_read_timeout(original) + + mock_io.temporary_timeout = _temporary_timeout + + driver.io = mock_io + driver._command_lock = asyncio.Lock() + return driver + + +class SendCommandTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.driver = _make_driver() + + async def test_simple_command(self) -> None: + """Test a simple command with echo + END response.""" + self.driver.io.readline.side_effect = [ + b"SPL\r\n", + b"SPL END 0\r\n", + ] + result = await self.driver.send_command("SPL 1") + self.assertEqual(result, []) + self.driver.io.write.assert_awaited_once_with(b"SPL 1\r") + + async def test_command_with_data_lines(self) -> None: + """Test a command that returns data lines between echo and END.""" + self.driver.io.readline.side_effect = [ + b"VER\r\n", + b"MultidropCombi 2.00.29 836-4191\r\n", + b"VER END 0\r\n", + ] + result = await self.driver.send_command("VER") + self.assertEqual(result, ["MultidropCombi 2.00.29 836-4191"]) + + async def test_command_with_error_status(self) -> None: + """Test that non-zero status raises MultidropCombiInstrumentError.""" + self.driver.io.readline.side_effect = [ + b"SPL\r\n", + b"SPL END 18\r\n", + ] + with self.assertRaises(MultidropCombiInstrumentError) as ctx: + await self.driver.send_command("SPL 99") + self.assertEqual(ctx.exception.status_code, 18) + self.assertIn("Invalid plate type", ctx.exception.description) + + async def test_timeout_raises_communication_error(self) -> None: + """Test that timeout (empty readline) raises MultidropCombiCommunicationError.""" + self.driver.io.readline.side_effect = [b""] + with self.assertRaises(MultidropCombiCommunicationError): + await self.driver.send_command("SPL 1") + + async def test_not_connected(self) -> None: + """Test that sending a command when not set up raises error.""" + self.driver._command_lock = None + with self.assertRaises(MultidropCombiCommunicationError): + await self.driver.send_command("VER") + + async def test_custom_timeout(self) -> None: + """Test that custom timeout is set during command and restored after.""" + self.driver.io.readline.side_effect = [ + b"POU\r\n", + b"POU END 0\r\n", + ] + original = self.driver.io.get_read_timeout() + await self.driver.send_command("POU", timeout=10.0) + self.assertEqual(self.driver.io.get_read_timeout(), original) + + async def test_echo_skipping_case_insensitive(self) -> None: + """Test that echo is skipped regardless of case.""" + self.driver.io.readline.side_effect = [ + b"ver\r\n", + b"MultidropCombi 2.00.29 836-4191\r\n", + b"VER END 0\r\n", + ] + result = await self.driver.send_command("VER") + self.assertEqual(result, ["MultidropCombi 2.00.29 836-4191"]) + + +class EnterRemoteModeTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.driver = _make_driver() + + async def test_enter_remote_mode_success(self) -> None: + """Test successful VER command parses instrument info.""" + self.driver.io.readline.side_effect = [ + b"VER\r\n", + b"MultidropCombi 2.00.29 836-4191\r\n", + b"VER END 0\r\n", + ] + info = await self.driver._enter_remote_mode() + self.assertEqual(info["instrument_name"], "MultidropCombi") + self.assertEqual(info["firmware_version"], "2.00.29") + self.assertEqual(info["serial_number"], "836-4191") + + async def test_enter_remote_mode_retry_after_eak(self) -> None: + """Test VER retry after EAK when first VER fails.""" + call_count = 0 + + async def readline_side_effect() -> bytes: + nonlocal call_count + call_count += 1 + responses = [ + b"VER\r\n", + b"VER END 1\r\n", + b"EAK\r\n", + b"EAK END 0\r\n", + b"VER\r\n", + b"MultidropCombi 2.00.29 836-4191\r\n", + b"VER END 0\r\n", + ] + if call_count <= len(responses): + return responses[call_count - 1] + return b"" + + self.driver.io.readline.side_effect = readline_side_effect + info = await self.driver._enter_remote_mode() + self.assertEqual(info["instrument_name"], "MultidropCombi") + + +class DrainStaleDataTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + self.driver = _make_driver() + + async def test_drain_with_stale_data(self) -> None: + """Test draining stale data from buffer.""" + self.driver.io.readline.side_effect = [ + b"stale line 1\r\n", + b"stale line 2\r\n", + b"", + ] + await self.driver._drain_stale_data() + self.driver.io.reset_input_buffer.assert_awaited_once() + self.driver.io.reset_output_buffer.assert_awaited_once() + + async def test_drain_empty_buffer(self) -> None: + """Test draining when buffer is already empty.""" + self.driver.io.readline.side_effect = [b""] + await self.driver._drain_stale_data() diff --git a/pylabrobot/thermo_fisher/multidrop_combi/tests/helpers_tests.py b/pylabrobot/thermo_fisher/multidrop_combi/tests/helpers_tests.py new file mode 100644 index 00000000000..842483fadb8 --- /dev/null +++ b/pylabrobot/thermo_fisher/multidrop_combi/tests/helpers_tests.py @@ -0,0 +1,209 @@ +import unittest + +from pylabrobot.thermo_fisher.multidrop_combi.helpers import ( + plate_to_pla_params, + plate_to_type_index, + plate_well_count, +) +from pylabrobot.resources import Plate, Well, create_ordered_items_2d +from pylabrobot.resources.well import CrossSectionType, WellBottomType + + +def _make_plate( + num_items_x: int = 12, + num_items_y: int = 8, + size_z: float = 14.2, + well_max_volume: float = 360.0, + well_size_z: float = 10.67, +) -> Plate: + """Create a test plate with the given parameters.""" + return Plate( + name="test_plate", + size_x=127.76, + size_y=85.48, + size_z=size_z, + model="test", + ordered_items=create_ordered_items_2d( + Well, + num_items_x=num_items_x, + num_items_y=num_items_y, + dx=10.0, + dy=7.0, + dz=1.0, + item_dx=9.0, + item_dy=9.0, + size_x=6.0, + size_y=6.0, + size_z=well_size_z, + bottom_type=WellBottomType.FLAT, + cross_section_type=CrossSectionType.CIRCLE, + max_volume=well_max_volume, + ), + ) + + +class PlateToTypeIndexTests(unittest.TestCase): + """Test factory plate type mapping.""" + + def test_96_well_short(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=14.0) + self.assertEqual(plate_to_type_index(plate), 0) # 15mm type + + def test_96_well_medium(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=20.0) + self.assertEqual(plate_to_type_index(plate), 1) # 22mm type + + def test_96_well_tall(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=40.0) + self.assertEqual(plate_to_type_index(plate), 2) # 44mm type + + def test_384_well_very_short(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=7.0) + self.assertEqual(plate_to_type_index(plate), 3) # 7.5mm type + + def test_384_well_short(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=10.0) + self.assertEqual(plate_to_type_index(plate), 4) # 10mm type + + def test_384_well_medium(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=14.0) + self.assertEqual(plate_to_type_index(plate), 5) # 15mm type + + def test_384_well_tall(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=25.0) + self.assertEqual(plate_to_type_index(plate), 6) # 22mm type + + def test_384_well_very_tall(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=44.0) + self.assertEqual(plate_to_type_index(plate), 7) # 44mm type + + def test_1536_well_short(self): + plate = _make_plate(num_items_x=48, num_items_y=32, size_z=5.0) + self.assertEqual(plate_to_type_index(plate), 8) # 5mm type + + def test_1536_well_tall(self): + plate = _make_plate(num_items_x=48, num_items_y=32, size_z=10.0) + self.assertEqual(plate_to_type_index(plate), 9) # 10.5mm type + + def test_unsupported_well_count(self): + plate = _make_plate(num_items_x=6, num_items_y=4) # 24-well + with self.assertRaises(ValueError) as ctx: + plate_to_type_index(plate) + self.assertIn("24", str(ctx.exception)) + + def test_96_well_too_tall(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=60.0) + with self.assertRaises(ValueError): + plate_to_type_index(plate) + + +class PlateToTypeIndexRealPlatesTests(unittest.TestCase): + """Test with real PLR plate definitions.""" + + def test_corning_96_well(self): + from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb + + plate = Cor_96_wellplate_360ul_Fb("test") + self.assertEqual(plate_to_type_index(plate), 0) # 14.2mm → type 0 + + def test_biorad_384_well(self): + from pylabrobot.resources.biorad.plates import BioRad_384_wellplate_50uL_Vb + + plate = BioRad_384_wellplate_50uL_Vb("test") + self.assertEqual(plate_to_type_index(plate), 4) # 10.4mm → type 4 + + +class PlateToPlaParamsTests(unittest.TestCase): + """Test PLA command parameter generation.""" + + def test_96_well_params(self): + plate = _make_plate(num_items_x=12, num_items_y=8, size_z=14.2, well_max_volume=360.0) + params = plate_to_pla_params(plate) + self.assertEqual(params["columns"], 12) + self.assertEqual(params["rows"], 8) + self.assertEqual(params["column_positions"], 12) + self.assertEqual(params["row_positions"], 8) + self.assertEqual(params["height"], 1420) # 14.2mm * 100 + self.assertEqual(params["max_volume"], 360.0) + + def test_384_well_params(self): + plate = _make_plate(num_items_x=24, num_items_y=16, size_z=10.4, well_max_volume=50.0) + params = plate_to_pla_params(plate) + self.assertEqual(params["columns"], 24) + self.assertEqual(params["rows"], 16) + self.assertEqual(params["height"], 1040) + self.assertEqual(params["max_volume"], 50.0) + + def test_real_corning_96_well(self): + from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb + + plate = Cor_96_wellplate_360ul_Fb("test") + params = plate_to_pla_params(plate) + self.assertEqual(params["columns"], 12) + self.assertEqual(params["rows"], 8) + self.assertEqual(params["height"], 1420) + self.assertEqual(params["max_volume"], 360.0) + + +class PlaParamsValidationTests(unittest.TestCase): + """Test parameter validation in plate_to_pla_params.""" + + def test_too_many_columns(self): + plate = _make_plate(num_items_x=49, num_items_y=8, size_z=14.0) + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("49 columns", str(ctx.exception)) + self.assertIn("48", str(ctx.exception)) + + def test_too_many_rows(self): + plate = _make_plate(num_items_x=12, num_items_y=33, size_z=14.0) + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("33 rows", str(ctx.exception)) + self.assertIn("32", str(ctx.exception)) + + def test_height_too_low(self): + plate = _make_plate(size_z=4.0) # 4mm < 5mm minimum + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("4.0mm", str(ctx.exception)) + self.assertIn("minimum", str(ctx.exception)) + + def test_height_too_high(self): + plate = _make_plate(size_z=60.0) # 60mm > 55mm maximum + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("60.0mm", str(ctx.exception)) + self.assertIn("maximum", str(ctx.exception)) + + def test_well_volume_too_high(self): + plate = _make_plate(well_max_volume=3000.0) # 3000uL > 2500uL max + with self.assertRaises(ValueError) as ctx: + plate_to_pla_params(plate) + self.assertIn("3000", str(ctx.exception)) + self.assertIn("2500", str(ctx.exception)) + + def test_height_at_minimum_boundary(self): + plate = _make_plate(size_z=5.0) # exactly 5mm = 500 hundredths + params = plate_to_pla_params(plate) + self.assertEqual(params["height"], 500) + + def test_height_at_maximum_boundary(self): + plate = _make_plate(size_z=55.0) # exactly 55mm = 5500 hundredths + params = plate_to_pla_params(plate) + self.assertEqual(params["height"], 5500) + + def test_volume_at_maximum_boundary(self): + plate = _make_plate(well_max_volume=2500.0) # exactly 2500uL + params = plate_to_pla_params(plate) + self.assertEqual(params["max_volume"], 2500.0) + + +class PlateWellCountTests(unittest.TestCase): + def test_96_well(self): + plate = _make_plate(num_items_x=12, num_items_y=8) + self.assertEqual(plate_well_count(plate), 96) + + def test_384_well(self): + plate = _make_plate(num_items_x=24, num_items_y=16) + self.assertEqual(plate_well_count(plate), 384) From 797451e27c8d794e6eef398c6cafacb55b17f2da Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 31 Mar 2026 21:30:38 -0700 Subject: [PATCH 29/69] Migrate EL406 and BioShake docs to manufacturer-based layout Move plate washer docs from 00_liquid-handling/plate-washing/ to agilent/biotek/el406/ and heater-shaker docs from 01_material-handling/heating_shaking/ to qinstruments/bioshake/. Add Manufacturers toctree section with manufacturer-level indexes. Include migration guide at repo root for future device migrations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../00_liquid-handling/_liquid-handling.rst | 1 - .../plate-washing/biotek-el406.ipynb | 298 ------------------ .../plate-washing/plate-washing.md | 14 - .../heating_shaking/heating_shaking.md | 3 +- .../heating_shaking/qinstruments.ipynb | 260 --------------- docs/user_guide/agilent/biotek/index.md | 7 + docs/user_guide/agilent/index.md | 7 + docs/user_guide/index.md | 9 + .../qinstruments/bioshake/hello-world.ipynb | 196 ++++++++++++ docs/user_guide/qinstruments/index.md | 7 + migration-guide-for-claude.md | 86 +++++ 11 files changed, 313 insertions(+), 575 deletions(-) delete mode 100644 docs/user_guide/00_liquid-handling/plate-washing/biotek-el406.ipynb delete mode 100644 docs/user_guide/00_liquid-handling/plate-washing/plate-washing.md delete mode 100644 docs/user_guide/01_material-handling/heating_shaking/qinstruments.ipynb create mode 100644 docs/user_guide/agilent/biotek/index.md create mode 100644 docs/user_guide/agilent/index.md create mode 100644 docs/user_guide/qinstruments/bioshake/hello-world.ipynb create mode 100644 docs/user_guide/qinstruments/index.md create mode 100644 migration-guide-for-claude.md diff --git a/docs/user_guide/00_liquid-handling/_liquid-handling.rst b/docs/user_guide/00_liquid-handling/_liquid-handling.rst index 32ea5d7d564..bbd2485cd3b 100644 --- a/docs/user_guide/00_liquid-handling/_liquid-handling.rst +++ b/docs/user_guide/00_liquid-handling/_liquid-handling.rst @@ -19,7 +19,6 @@ Examples: hamilton-prep/_hamilton-prep opentrons/ot2/ot2 tecan-evo/_tecan-evo - plate-washing/plate-washing pumps/_pumps moving-channels-around tutorial_tip_inventory_consolidation diff --git a/docs/user_guide/00_liquid-handling/plate-washing/biotek-el406.ipynb b/docs/user_guide/00_liquid-handling/plate-washing/biotek-el406.ipynb deleted file mode 100644 index f9b377006fd..00000000000 --- a/docs/user_guide/00_liquid-handling/plate-washing/biotek-el406.ipynb +++ /dev/null @@ -1,298 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# BioTek EL406\n", - "\n", - "The BioTek EL406 plate washer is controlled by the {class}`~pylabrobot.plate_washing.biotek.el406.BioTekEL406Backend` class. It communicates via FTDI USB serial.\n", - "\n", - "The EL406 has three fluid delivery subsystems — manifold, syringe pump, and peristaltic pump — plus an integrated plate shaker. All operations require a {class}`~pylabrobot.resources.Plate` object to configure plate-specific parameters automatically." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Setup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_washing.biotek.el406 import ExperimentalBioTekEL406Backend\n", - "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", - "\n", - "backend = ExperimentalBioTekEL406Backend() # ExperimentalBioTekEL406Backend(device_id=\"YOUR_FTDI_ID_HERE\")\n", - "await backend.setup()\n", - "\n", - "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Manifold\n", - "\n", - "The wash manifold is the primary fluid system. Prime the lines before use to fill tubing with buffer." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.manifold_prime(plate, volume=10000, buffer=\"A\") # 10 mL" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Dispense and aspirate individually, or use `manifold_wash` for repeated dispense-aspirate cycles." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.manifold_dispense(plate, volume=200, buffer=\"A\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.manifold_aspirate(plate)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.manifold_wash(plate, cycles=3, buffer=\"A\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "`manifold_wash` supports many options: shake/soak between cycles, secondary aspirate, bottom wash, and per-cycle pre-dispense." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.manifold_wash(\n", - " plate,\n", - " cycles=3,\n", - " buffer=\"A\",\n", - " dispense_volume=300,\n", - " soak_duration=10,\n", - " shake_duration=5,\n", - " shake_intensity=\"Medium\",\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Run an auto-clean cycle to flush the manifold lines." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.manifold_auto_clean(plate, buffer=\"A\", duration=60)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Syringe pump\n", - "\n", - "The syringe pump provides precise low-volume dispensing." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.syringe_prime(plate, syringe=\"A\", volume=5000, flow_rate=5, refills=2)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.syringe_dispense(plate, volume=50, syringe=\"A\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Peristaltic pump" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.peristaltic_prime(plate, volume=300, flow_rate=\"High\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.peristaltic_dispense(plate, volume=100, flow_rate=\"High\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.peristaltic_purge(plate, volume=300, flow_rate=\"High\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Shaking" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.shake(plate, duration=30, intensity=\"Medium\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Batching\n", - "\n", - "Each step command automatically starts and cleans up a batch. To group multiple steps into a single batch (avoiding repeated start/cleanup overhead), use the `batch()` context manager." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "async with backend.batch(plate):\n", - " await backend.manifold_prime(plate, volume=10000, buffer=\"A\")\n", - " await backend.manifold_wash(plate, cycles=3, buffer=\"A\")\n", - " await backend.manifold_aspirate(plate)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Queries" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.request_serial_number()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.request_washer_manifold()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.request_instrument_settings()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Teardown" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await backend.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.25" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/00_liquid-handling/plate-washing/plate-washing.md b/docs/user_guide/00_liquid-handling/plate-washing/plate-washing.md deleted file mode 100644 index 08b07f905ff..00000000000 --- a/docs/user_guide/00_liquid-handling/plate-washing/plate-washing.md +++ /dev/null @@ -1,14 +0,0 @@ -# Plate Washing - -Plate washers automate the process of dispensing and aspirating wash buffers from microplates. They are commonly used in ELISA, cell-based assays, and other workflows that require repeated wash cycles. - -## Supported Plate Washers - -- BioTek EL406 - -```{toctree} -:maxdepth: 1 -:hidden: - -BioTek EL406 -``` diff --git a/docs/user_guide/01_material-handling/heating_shaking/heating_shaking.md b/docs/user_guide/01_material-handling/heating_shaking/heating_shaking.md index 5f863f563ea..a5b2a4f79ab 100644 --- a/docs/user_guide/01_material-handling/heating_shaking/heating_shaking.md +++ b/docs/user_guide/01_material-handling/heating_shaking/heating_shaking.md @@ -18,12 +18,11 @@ PyLabRobot supports the following heater shakers: - Inheco ThermoShake (should have the same API as RM) - Inheco ThermoShake AC (should have the same API as RM) - Hamilton Heater Shaker (tested) -- QInstruments BioShake (3000 elmm, 5000 elm, and D30-T elm tested) +- QInstruments BioShake (3000 elm, 5000 elm, and D30-T elm tested) ```{toctree} :maxdepth: 1 Inheco ThermoShake Hamilton Heater Shaker -QInstruments BioShake ``` diff --git a/docs/user_guide/01_material-handling/heating_shaking/qinstruments.ipynb b/docs/user_guide/01_material-handling/heating_shaking/qinstruments.ipynb deleted file mode 100644 index 854bca2164e..00000000000 --- a/docs/user_guide/01_material-handling/heating_shaking/qinstruments.ipynb +++ /dev/null @@ -1,260 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# QInstruments BioShake\n", - "\n", - "The QInstruments BioShake is a series of heater-cooler-shaker machines that enables:\n", - "- heating & cooling,\n", - "- locking & unlocking, and\n", - "- (orbital) shaking\n", - "\n", - "...of plates, depending on the model.\n", - "\n", - "Because all models share the same firmware, the table below lists every model supported by this backend, as well as what features they support. If a feature is called on a model that doesn't support it (e.g. shaking on a Heatplate), then a 'not supported' error will be rasied.\n", - "\n", - "| Model Name | Shaking (rpm) | Plate Lock | Heating | Active Cooling |\n", - "|----------------------|---------------|------------|---------|----------------|\n", - "| BioShake Q1 | 200-3000 | ✔️ | ✔️ | ✔️ |\n", - "| BioShake Q2 | 200-3000 | ✔️ | ✔️ | ✔️ |\n", - "| BioShake 3000 | 200-3000 | ❌ | ❌ | ❌ |\n", - "| BioShake 3000 elm | 200-3000 | ✔️ | ❌ | ❌ |\n", - "| BioShake 3000 elm DWP| 200-3000 | ✔️ | ❌ | ❌ |\n", - "| BioShake D30 elm | 200-2000 | ✔️ | ❌ | ❌ |\n", - "| BioShake 5000 elm | 200-5000 | ✔️ | ❌ | ❌ |\n", - "| BioShake 3000-T | 200-3000 | ❌ | ✔️ | ❌ |\n", - "| BioShake 3000-T elm | 200-3000 | ✔️ | ✔️ | ❌ |\n", - "| BioShake D30-T elm | 200-2000 | ✔️ | ✔️ | ❌ |\n", - "| Heatplate | none | ❌ | ✔️ | ❌ |\n", - "| ColdPlate | none | ❌ | ✔️ | ✔️ |\n", - "\n", - "\n", - "Check out the [BioShake integration manual](https://www.qinstruments.com/fileadmin/Article/All/integration-manual-en-1-8-0.pdf) for more information (or this [manual](https://www.qinstruments.com/fileadmin/Article/MANUALS/Integration-manual-en.pdf) for the Q1 and Q2 models, specifically.)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Setup Instructions (Physical)\n", - "\n", - "Please refer to the above manuals for instructions on connecting the BioShake devices to the computer. They can be connected via RS232 or USB-A port and must be plugged into a 24 VDC power supply." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Usage" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.heating_shaking import HeaterShaker\n", - "from pylabrobot.heating_shaking import BioShake\n", - "from pylabrobot.resources.coordinate import Coordinate\n", - "\n", - "str_port = \"COM1\" # Replace with the actual port connected to your computer\n", - "backend = BioShake(port=str_port)\n", - "hs = HeaterShaker(\n", - " name=\"BioShake\",\n", - " size_x=0, # TODO: physical size\n", - " size_y=0, # TODO: physical size\n", - " size_z=0, # TODO: physical size\n", - " child_location=Coordinate(0, 0, 0), # TODO: physical size\n", - " backend=backend)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "When calling `setup`, the user has the option to home the device or not. By default, the device will reset (clearing any error it's stuck in) before moving to the home zero position and locks in place. This process should take no longer than 30 seconds." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.setup(skip_home=False) # Or 'True' if you wish to skip the process" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Temperature Control\n", - "\n", - "For models that support temperature control, please use the following call functions:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.set_temperature(temperature=37) # Temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.get_temperature() # Returns temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.deactivate() # Stop temperature control" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Shaking Control" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For models that support shaking and/or plate locking, please use the following call functions:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.lock_plate()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.unlock_plate()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "BioShake supports acceleration for shaking and deceleration for stopping. Acceleration and deceleration correspond to the seconds it takes till it reaches full speed. The default value is 0, where it starts and stops at normal velocity. Any value higher will result in a slow acceleration/deceleration." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.shake(speed=500, acceleration=0) # speed in rpm" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.stop_shaking(deceleration=5) # Stop shaking" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Closing Connection to Machine" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.stop()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Using Multiple BioShake Devices" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "When using multiple BioShake devices, you may call them in batches." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import asyncio\n", - "\n", - "\n", - "async def setup_and_shake(hs):\n", - " await hs.setup(skip_home=False)\n", - " await hs.shake(speed=500)\n", - "\n", - "await asyncio.gather(\n", - " setup_and_shake(hs1),\n", - " setup_and_shake(hs2),\n", - " setup_and_shake(hs3),\n", - " setup_and_shake(hs4),\n", - ")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/agilent/biotek/index.md b/docs/user_guide/agilent/biotek/index.md new file mode 100644 index 00000000000..df56e26c37f --- /dev/null +++ b/docs/user_guide/agilent/biotek/index.md @@ -0,0 +1,7 @@ +# BioTek + +```{toctree} +:maxdepth: 1 + +el406/hello-world +``` diff --git a/docs/user_guide/agilent/index.md b/docs/user_guide/agilent/index.md new file mode 100644 index 00000000000..c1f145e9bfd --- /dev/null +++ b/docs/user_guide/agilent/index.md @@ -0,0 +1,7 @@ +# Agilent + +```{toctree} +:maxdepth: 1 + +biotek/index +``` diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 5f3c37d64b1..be1e6217ac6 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -25,6 +25,15 @@ definitions 02_analytical/_analytical ``` +```{toctree} +:maxdepth: 1 +:caption: Manufacturers +:hidden: + +agilent/index +qinstruments/index +``` + ```{toctree} :maxdepth: 1 :caption: Machine-Agnostic Features diff --git a/docs/user_guide/qinstruments/bioshake/hello-world.ipynb b/docs/user_guide/qinstruments/bioshake/hello-world.ipynb new file mode 100644 index 00000000000..b8bab0c645e --- /dev/null +++ b/docs/user_guide/qinstruments/bioshake/hello-world.ipynb @@ -0,0 +1,196 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": "# QInstruments BioShake\n\nThe BioShake is a family of heater-cooler-shaker devices from QInstruments. Depending on the model, it supports:\n\n- [Shaking](../../capabilities/shaking) (orbital, 200--5000 rpm)\n- [Temperature control](../../capabilities/temperature-control) (heating, and active cooling on Q1/Q2/ColdPlate)\n- Plate locking (ELM models)\n\nAll models share the same serial firmware, so a single driver covers the entire family. Use a **model-specific factory function** to create instances -- it pre-fills dimensions and enables only the capabilities your hardware supports.\n\n| Model | PLR Name | Shaking (rpm) | Plate Lock | Heating | Active Cooling |\n|---|---|---|---|---|---|\n| BioShake Q1 | `BioShakeQ1` | 200--3000 | yes | yes | yes |\n| BioShake Q2 | `BioShakeQ2` | 200--3000 | yes | yes | yes |\n| BioShake 3000 | `BioShake3000` | 200--3000 | no | no | no |\n| BioShake 3000 elm | `BioShake3000Elm` | 200--3000 | yes | no | no |\n| BioShake 3000 elm DWP | `BioShake3000ElmDWP` | 200--3000 | yes | no | no |\n| BioShake D30 elm | `BioShakeD30Elm` | 200--2000 | yes | no | no |\n| BioShake 5000 elm | `BioShake5000Elm` | 200--5000 | yes | no | no |\n| BioShake 3000-T | `BioShake3000T` | 200--3000 | no | yes | no |\n| BioShake 3000-T elm | `BioShake3000TElm` | 200--3000 | yes | yes | no |\n| BioShake D30-T elm | `BioShakeD30TElm` | 200--2000 | yes | yes | no |\n| Heatplate | `Heatplate` | -- | no | yes | no |\n| ColdPlate | `ColdPlate` | -- | no | yes | yes |\n\nSee the [BioShake integration manual](https://www.qinstruments.com/fileadmin/Article/All/integration-manual-en-1-8-0.pdf) for hardware setup. Connect via RS232 or USB-A with a 24 VDC power supply." + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.qinstruments import BioShakeQ1\n", + "\n", + "bs = BioShakeQ1(name=\"bioshake\", port=\"COM1\") # replace with your port\n", + "await bs.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "By default, `setup()` resets the device and homes the shaker. Pass `skip_home=True` to skip this." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Shaking\n", + "\n", + "The BioShake exposes a {class}`~pylabrobot.capabilities.shaking.shaking.Shaker` on `bs.shaker`. For the full API, see [Shaking](../../capabilities/shaking)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Shake for 60 seconds at 500 rpm (blocks until done)\n", + "await bs.shaker.shake(speed=500, duration=60)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Or start/stop manually\n", + "await bs.shaker.shake(speed=300)\n", + "# ... do other things ...\n", + "await bs.shaker.stop_shaking()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "BioShake supports acceleration and deceleration ramps (seconds to reach full speed). Pass these as backend params:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await bs.shaker.shake(speed=500, acceleration=3)\n", + "await bs.shaker.stop_shaking(deceleration=5)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lock and unlock the plate (ELM models):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await bs.shaker.lock_plate()\n", + "await bs.shaker.shake(speed=1000, duration=10)\n", + "await bs.shaker.unlock_plate()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Temperature control\n", + "\n", + "Models with heating/cooling expose a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `bs.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await bs.tc.set_temperature(37.0)\n", + "await bs.tc.wait_for_temperature(tolerance=0.5)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "current = await bs.tc.request_temperature()\n", + "print(f\"{current:.1f} \\u00b0C\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await bs.tc.deactivate()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Multiple devices\n", + "\n", + "When using multiple BioShake devices, run them concurrently with `asyncio.gather`:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import asyncio\n", + "from pylabrobot.qinstruments import BioShake3000Elm\n", + "\n", + "bs1 = BioShake3000Elm(name=\"bs1\", port=\"COM1\")\n", + "bs2 = BioShake3000Elm(name=\"bs2\", port=\"COM2\")\n", + "\n", + "await asyncio.gather(bs1.setup(), bs2.setup())\n", + "await asyncio.gather(\n", + " bs1.shaker.shake(speed=500, duration=30),\n", + " bs2.shaker.shake(speed=500, duration=30),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await bs.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "env", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/docs/user_guide/qinstruments/index.md b/docs/user_guide/qinstruments/index.md new file mode 100644 index 00000000000..47dc059b4ef --- /dev/null +++ b/docs/user_guide/qinstruments/index.md @@ -0,0 +1,7 @@ +# QInstruments + +```{toctree} +:maxdepth: 1 + +bioshake/hello-world +``` diff --git a/migration-guide-for-claude.md b/migration-guide-for-claude.md new file mode 100644 index 00000000000..8b74102c4e8 --- /dev/null +++ b/migration-guide-for-claude.md @@ -0,0 +1,86 @@ +# Docs migration guide: legacy category-based to manufacturer-based layout + +## Background + +The PLR codebase organizes device code by manufacturer (e.g. `pylabrobot/agilent/biotek/el406/`), but the docs historically used category-based directories: + +- `docs/user_guide/00_liquid-handling/` +- `docs/user_guide/01_material-handling/` +- `docs/user_guide/02_analytical/` + +We are migrating docs to mirror the codebase: `docs/user_guide//...`. + +This file lives at the repo root (not inside `docs/`) so Sphinx doesn't complain about it not being in a toctree. + +## How to migrate a single device + +### 1. Create the new doc directory + +Mirror the code path. For example: +- Code at `pylabrobot/qinstruments/bioshake.py` -> docs at `docs/user_guide/qinstruments/bioshake/` +- Code at `pylabrobot/agilent/biotek/el406/` -> docs at `docs/user_guide/agilent/biotek/el406/` + +### 2. Write the notebook + +Create a `hello-world.ipynb` at the new location. The notebook should: + +- **Import from the new code path** (e.g. `from pylabrobot.qinstruments import BioShakeQ1`, not `from pylabrobot.heating_shaking import BioShake`). +- **Show device setup and teardown.** +- **Give brief demos of each capability** the device supports (shaking, temperature control, etc.) — just enough to show the device-specific API surface (factory functions, backend params, model-specific notes). +- **Link to the capability docs for full API details** rather than duplicating them. Use relative links like `[Shaking](../../capabilities/shaking)` and `[Temperature Control](../../capabilities/temperature-control)`. +- **Include a model table** if the device has multiple models/variants, with a "PLR Name" column showing the factory function or class name. + +See `docs/user_guide/qinstruments/bioshake/hello-world.ipynb` as the reference example. + +### 3. Wire up the toctree + +The Manufacturers section in `docs/user_guide/index.md` lists manufacturer-level indexes. Each manufacturer has an `index.md` that lists its devices. + +**When there's only one device under a level, skip the intermediate index and point directly to the notebook.** Only create an `index.md` when a level has multiple children. + +Current structure: + +``` +docs/user_guide/index.md (Manufacturers toctree) +├── agilent/index.md -> lists biotek/index +│ └── biotek/index.md -> lists el406/hello-world (no el406/index.md — only one item) +└── qinstruments/index.md -> lists bioshake/hello-world (no bioshake/index.md — only one item) +``` + +**Adding a device to an existing manufacturer:** add the notebook path to the manufacturer's `index.md` toctree. If the manufacturer previously pointed directly to a single notebook, you'll need to create an intermediate `index.md` now that there are multiple items. + +**Adding a new manufacturer:** create `/index.md` and add it to the Manufacturers toctree in `docs/user_guide/index.md`. + +### 4. Remove the old notebook from the legacy location + +Delete the old `.ipynb` file from `00_liquid-handling/`, `01_material-handling/`, or `02_analytical/`. + +### 5. Remove the entry from the legacy category toctree + +Remove the device's toctree entry from the parent page (e.g. `heating_shaking.md`, `plate-washing.md`). If a sub-section becomes empty after removal, delete the entire sub-section directory too. The goal is to eventually delete `00_liquid-handling/`, `01_material-handling/`, and `02_analytical/` entirely. + +Do NOT update other text/links in the legacy pages — just remove the toctree entry and the file. + +### 6. Do NOT touch `machines.md` + +`machines.md` is legacy and will be kept as-is. Don't update links there. + +### 7. Build and verify + +Run `make clean-docs && make docs-fast` to build pages from scratch (no API docs). Fix any warnings — the build uses `-W` so warnings are errors. + +## Rules + +- Use **relative links** between doc pages, not absolute `https://docs.pylabrobot.org/...` URLs. +- The directory structure under `docs/user_guide/` should mirror the package structure under `pylabrobot/`. +- Migrate one device at a time. Don't batch. +- When a device has capabilities (shaking, temperature control, etc.), link to the capability docs — don't duplicate the API walkthrough. +- Include a "PLR Name" column in model tables showing the factory function or class name users should import. +- Skip intermediate `index.md` files when a level has only one child — point directly to the notebook instead. + +## Completed migrations + +| Device | Old location | New location | +|--------|-------------|--------------| +| BioTek EL406 | `00_liquid-handling/plate-washing/biotek-el406.ipynb` | `agilent/biotek/el406/hello-world.ipynb` | +| QInstruments BioShake | `01_material-handling/heating_shaking/qinstruments.ipynb` | `qinstruments/bioshake/hello-world.ipynb` | From 27fdbd7e69b2112c65cbdb9d5defc5f59fc56957 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 31 Mar 2026 22:13:38 -0700 Subject: [PATCH 30/69] Migrate EL406, BioShake, and 10 more devices to manufacturer-based docs Move device docs from legacy category dirs (00_liquid-handling/, 01_material-handling/, 02_analytical/) to manufacturer-based layout mirroring the codebase. Add Manufacturers toctree section and API reference RST files for all manufacturers with autosummary directives and autoclass for nested BackendParams. Add Sphinx cross-references for BackendParams in notebook markdown cells. Devices migrated: EL406, BioShake, Mettler Toledo WXS205SDU, Azenta a4S, Azenta XPeel, Liconic STX, Inheco ThermoShake, Inheco CPAC, Inheco SCILA, Inheco Incubator Shaker, Inheco ODTC, Thermo Fisher Multidrop Combi. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/api/pylabrobot.agilent.rst | 124 ++ docs/api/pylabrobot.azenta.rst | 47 + docs/api/pylabrobot.capabilities.rst | 66 + docs/api/pylabrobot.inheco.rst | 71 + docs/api/pylabrobot.liconic.rst | 22 + docs/api/pylabrobot.mettler_toledo.rst | 15 + docs/api/pylabrobot.qinstruments.rst | 28 + docs/api/pylabrobot.rst | 14 + docs/api/pylabrobot.thermo_fisher.rst | 114 ++ .../heating_shaking/heating_shaking.md | 1 - .../heating_shaking/inheco.ipynb | 229 ---- .../01_material-handling/sealers/a4s.ipynb | 285 ---- .../01_material-handling/sealers/sealers.md | 1 - .../storage/inheco/incubator_shaker.ipynb | 1191 ----------------- .../storage/inheco/scila.ipynb | 480 ------- .../storage/liconic.ipynb | 260 ---- .../01_material-handling/storage/storage.rst | 3 - .../temperature-controllers/inheco.ipynb | 222 --- .../temperature-controllers.rst | 1 - .../thermocycling/inheco-odtc.ipynb | 316 ----- .../thermocycling/thermocycling.md | 5 - .../scales/mettler-toledo-WXS205SDU.ipynb | 328 ----- .../02_analytical/scales/scales.rst | 2 - .../agilent/biotek/el406/hello-world.ipynb | 14 +- docs/user_guide/azenta/a4s/hello-world.ipynb | 135 ++ docs/user_guide/azenta/a4s/img/azenta_a4s.png | Bin 0 -> 183206 bytes docs/user_guide/azenta/index.md | 8 + .../user_guide/azenta/xpeel/hello-world.ipynb | 85 ++ .../capabilities/dispensing/index.md | 52 + .../capabilities/dispensing/peristaltic.ipynb | 152 +++ .../capabilities/dispensing/syringe.ipynb | 121 ++ docs/user_guide/capabilities/index.md | 1 + docs/user_guide/index.md | 5 + docs/user_guide/inheco/cpac/hello-world.ipynb | 133 ++ .../inheco/incubator_shaker/hello-world.ipynb | 215 +++ docs/user_guide/inheco/index.md | 11 + docs/user_guide/inheco/odtc/hello-world.ipynb | 159 +++ .../user_guide/inheco/scila/hello-world.ipynb | 187 +++ .../inheco/scila/img/inheco_scila.png | Bin 0 -> 374900 bytes .../inheco/thermoshake/hello-world.ipynb | 123 ++ docs/user_guide/liconic/index.md | 7 + docs/user_guide/liconic/stx/hello-world.ipynb | 177 +++ docs/user_guide/mettler_toledo/index.md | 7 + .../wxs205sdu/hello-world.ipynb | 113 ++ docs/user_guide/thermo_fisher/index.md | 7 + .../multidrop_combi/hello-world.ipynb | 302 +++++ migration-guide-for-claude.md | 53 +- pylabrobot/bulk_dispensers/__init__.py | 3 - pylabrobot/bulk_dispensers/backend.py | 84 -- pylabrobot/bulk_dispensers/bulk_dispenser.py | 60 - pylabrobot/bulk_dispensers/chatterbox.py | 47 - pylabrobot/bulk_dispensers/tests/__init__.py | 0 .../tests/bulk_dispenser_tests.py | 91 -- .../thermo_scientific/__init__.py | 13 - .../multidrop_combi/__init__.py | 19 - .../multidrop_combi/actions.py | 30 - .../multidrop_combi/backend.py | 123 -- .../multidrop_combi/commands.py | 251 ---- .../multidrop_combi/communication.py | 223 --- .../multidrop_combi/demo_multidrop.py | 164 --- .../multidrop_combi/enums.py | 25 - .../multidrop_combi/errors.py | 25 - .../multidrop_combi/helpers.py | 139 -- .../multidrop_combi/queries.py | 50 - .../multidrop_combi/tests/__init__.py | 0 .../multidrop_combi/tests/backend_tests.py | 63 - .../multidrop_combi/tests/commands_tests.py | 168 --- .../tests/communication_tests.py | 162 --- .../multidrop_combi/tests/helpers_tests.py | 209 --- .../automated_retrieval.py | 5 +- .../barcode_scanning/barcode_scanning.py | 5 +- .../bulk_dispensers/peristaltic/__init__.py | 1 + .../bulk_dispensers/peristaltic/chatterbox.py | 41 + .../peristaltic/peristaltic.py | 5 +- .../bulk_dispensers/syringe/__init__.py | 1 + .../bulk_dispensers/syringe/chatterbox.py | 29 + .../bulk_dispensers/syringe/syringe.py | 5 +- .../capabilities/centrifuging/centrifuging.py | 5 +- .../capabilities/fan_control/fan_control.py | 5 +- .../humidity_controller.py | 5 +- .../capabilities/liquid_handling/head96.py | 2 + .../capabilities/liquid_handling/pip.py | 2 + .../capabilities/microscopy/microscopy.py | 2 + pylabrobot/capabilities/peeling/peeling.py | 5 +- .../plate_reading/absorbance/absorbance.py | 5 +- .../fluorescence/fluorescence.py | 5 +- .../luminescence/luminescence.py | 5 +- pylabrobot/capabilities/pumping/pumping.py | 5 +- pylabrobot/capabilities/sealing/sealing.py | 5 +- pylabrobot/capabilities/shaking/shaking.py | 5 +- .../temperature_controller.py | 5 +- pylabrobot/capabilities/tilting/tilting.py | 5 +- pylabrobot/capabilities/weighing/weighing.py | 5 +- .../multidrop_combi/multidrop_combi.py | 2 +- 94 files changed, 2697 insertions(+), 5309 deletions(-) create mode 100644 docs/api/pylabrobot.agilent.rst create mode 100644 docs/api/pylabrobot.azenta.rst create mode 100644 docs/api/pylabrobot.inheco.rst create mode 100644 docs/api/pylabrobot.liconic.rst create mode 100644 docs/api/pylabrobot.mettler_toledo.rst create mode 100644 docs/api/pylabrobot.qinstruments.rst create mode 100644 docs/api/pylabrobot.thermo_fisher.rst delete mode 100644 docs/user_guide/01_material-handling/heating_shaking/inheco.ipynb delete mode 100644 docs/user_guide/01_material-handling/sealers/a4s.ipynb delete mode 100644 docs/user_guide/01_material-handling/storage/inheco/incubator_shaker.ipynb delete mode 100644 docs/user_guide/01_material-handling/storage/inheco/scila.ipynb delete mode 100644 docs/user_guide/01_material-handling/storage/liconic.ipynb delete mode 100644 docs/user_guide/01_material-handling/temperature-controllers/inheco.ipynb delete mode 100644 docs/user_guide/01_material-handling/thermocycling/inheco-odtc.ipynb delete mode 100644 docs/user_guide/02_analytical/scales/mettler-toledo-WXS205SDU.ipynb create mode 100644 docs/user_guide/azenta/a4s/hello-world.ipynb create mode 100644 docs/user_guide/azenta/a4s/img/azenta_a4s.png create mode 100644 docs/user_guide/azenta/index.md create mode 100644 docs/user_guide/azenta/xpeel/hello-world.ipynb create mode 100644 docs/user_guide/capabilities/dispensing/index.md create mode 100644 docs/user_guide/capabilities/dispensing/peristaltic.ipynb create mode 100644 docs/user_guide/capabilities/dispensing/syringe.ipynb create mode 100644 docs/user_guide/inheco/cpac/hello-world.ipynb create mode 100644 docs/user_guide/inheco/incubator_shaker/hello-world.ipynb create mode 100644 docs/user_guide/inheco/index.md create mode 100644 docs/user_guide/inheco/odtc/hello-world.ipynb create mode 100644 docs/user_guide/inheco/scila/hello-world.ipynb create mode 100644 docs/user_guide/inheco/scila/img/inheco_scila.png create mode 100644 docs/user_guide/inheco/thermoshake/hello-world.ipynb create mode 100644 docs/user_guide/liconic/index.md create mode 100644 docs/user_guide/liconic/stx/hello-world.ipynb create mode 100644 docs/user_guide/mettler_toledo/index.md create mode 100644 docs/user_guide/mettler_toledo/wxs205sdu/hello-world.ipynb create mode 100644 docs/user_guide/thermo_fisher/index.md create mode 100644 docs/user_guide/thermo_fisher/multidrop_combi/hello-world.ipynb delete mode 100644 pylabrobot/bulk_dispensers/__init__.py delete mode 100644 pylabrobot/bulk_dispensers/backend.py delete mode 100644 pylabrobot/bulk_dispensers/bulk_dispenser.py delete mode 100644 pylabrobot/bulk_dispensers/chatterbox.py delete mode 100644 pylabrobot/bulk_dispensers/tests/__init__.py delete mode 100644 pylabrobot/bulk_dispensers/tests/bulk_dispenser_tests.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/__init__.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/__init__.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/actions.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/backend.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/commands.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/communication.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/demo_multidrop.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/enums.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/errors.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/helpers.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/queries.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/__init__.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/backend_tests.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/commands_tests.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/communication_tests.py delete mode 100644 pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/helpers_tests.py create mode 100644 pylabrobot/capabilities/bulk_dispensers/peristaltic/chatterbox.py create mode 100644 pylabrobot/capabilities/bulk_dispensers/syringe/chatterbox.py diff --git a/docs/api/pylabrobot.agilent.rst b/docs/api/pylabrobot.agilent.rst new file mode 100644 index 00000000000..21432b5bc10 --- /dev/null +++ b/docs/api/pylabrobot.agilent.rst @@ -0,0 +1,124 @@ +.. currentmodule:: pylabrobot.agilent + +pylabrobot.agilent package +========================== + +BioTek EL406 +------------ + +.. currentmodule:: pylabrobot.agilent.biotek.el406.el406 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + EL406 + EL406Driver + +.. currentmodule:: pylabrobot.agilent.biotek.el406.plate_washing_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + EL406PlateWashingBackend + +.. autoclass:: pylabrobot.agilent.biotek.el406.plate_washing_backend.EL406PlateWashingBackend.WashParams + :members: + +.. autoclass:: pylabrobot.agilent.biotek.el406.plate_washing_backend.EL406PlateWashingBackend.PrimeParams + :members: + +.. currentmodule:: pylabrobot.agilent.biotek.el406.shaking_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + EL406ShakingBackend + +.. currentmodule:: pylabrobot.agilent.biotek.el406.syringe_dispensing_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + EL406SyringeDispensingBackend + +.. autoclass:: pylabrobot.agilent.biotek.el406.syringe_dispensing_backend.EL406SyringeDispensingBackend.DispenseParams + :members: + +.. autoclass:: pylabrobot.agilent.biotek.el406.syringe_dispensing_backend.EL406SyringeDispensingBackend.PrimeParams + :members: + +.. currentmodule:: pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + EL406PeristalticDispensingBackend + +.. autoclass:: pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend.EL406PeristalticDispensingBackend.DispenseParams + :members: + +.. autoclass:: pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend.EL406PeristalticDispensingBackend.PrimeParams + :members: + + +BioTek Cytation +--------------- + +.. currentmodule:: pylabrobot.agilent.biotek.cytation + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Cytation1 + Cytation5 + CytationBackend + CytationImagingConfig + +.. autoclass:: pylabrobot.agilent.biotek.cytation.CytationBackend.CaptureParams + :members: + + +BioTek Synergy H1 +------------------ + +.. currentmodule:: pylabrobot.agilent.biotek.synergy_h1 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SynergyH1 + SynergyH1Backend + + +VSpin +----- + +.. currentmodule:: pylabrobot.agilent.vspin + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + VSpin + VSpinDriver + VSpinCentrifugeBackend + Access2 + Access2Driver + +.. autoclass:: pylabrobot.agilent.vspin.VSpinCentrifugeBackend.SpinParams + :members: diff --git a/docs/api/pylabrobot.azenta.rst b/docs/api/pylabrobot.azenta.rst new file mode 100644 index 00000000000..9012ce85723 --- /dev/null +++ b/docs/api/pylabrobot.azenta.rst @@ -0,0 +1,47 @@ +.. currentmodule:: pylabrobot.azenta + +pylabrobot.azenta package +========================= + +a4S Sealer +---------- + +.. currentmodule:: pylabrobot.azenta.a4s + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + A4S + A4SDriver + A4SSealerBackend + A4STemperatureBackend + A4SStatus + +.. autoclass:: pylabrobot.azenta.a4s.A4SStatus.SystemStatus + :members: + +.. autoclass:: pylabrobot.azenta.a4s.A4SStatus.HeaterBlockStatus + :members: + +.. autoclass:: pylabrobot.azenta.a4s.A4SStatus.SensorStatus + :members: + + +XPeel Peeler +------------ + +.. currentmodule:: pylabrobot.azenta.xpeel + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + XPeel + XPeelDriver + XPeelPeelerBackend + +.. autoclass:: pylabrobot.azenta.xpeel.XPeelPeelerBackend.PeelParams + :members: diff --git a/docs/api/pylabrobot.capabilities.rst b/docs/api/pylabrobot.capabilities.rst index 14ddb6a4197..bb9b630a5be 100644 --- a/docs/api/pylabrobot.capabilities.rst +++ b/docs/api/pylabrobot.capabilities.rst @@ -240,6 +240,72 @@ Plate Reading - Luminescence LuminescenceBackend +Devices +------- + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.multidrop_combi + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MultidropCombi + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.driver + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MultidropCombiDriver + + +Bulk Dispensing - Peristaltic +----------------------------- + +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.peristaltic.peristaltic + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PeristalticDispensing + +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.peristaltic.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PeristalticDispensingBackend + + +Bulk Dispensing - Syringe +------------------------- + +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.syringe.syringe + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SyringeDispensing + +.. currentmodule:: pylabrobot.capabilities.bulk_dispensers.syringe.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SyringeDispensingBackend + + Liquid Handling - PIP (Independent Channels) -------------------------------------------- diff --git a/docs/api/pylabrobot.inheco.rst b/docs/api/pylabrobot.inheco.rst new file mode 100644 index 00000000000..8100f0702b9 --- /dev/null +++ b/docs/api/pylabrobot.inheco.rst @@ -0,0 +1,71 @@ +.. currentmodule:: pylabrobot.inheco + +pylabrobot.inheco package +========================= + +TEC Control Box +--------------- + +.. currentmodule:: pylabrobot.inheco.control_box + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + InhecoTECControlBox + + +ThermoShake +----------- + +.. currentmodule:: pylabrobot.inheco.thermoshake + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + InhecoThermoShake + InhecoThermoshakeBackend + inheco_thermoshake + inheco_thermoshake_ac + inheco_thermoshake_rm + + +CPAC +---- + +.. currentmodule:: pylabrobot.inheco.cpac + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + InhecoCPAC + InhecoCPACBackend + inheco_cpac_ultraflat + + +SCILA +----- + +.. currentmodule:: pylabrobot.inheco.scila.scila + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SCILA + +.. currentmodule:: pylabrobot.inheco.scila.scila_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SCILADriver + SCILATemperatureBackend diff --git a/docs/api/pylabrobot.liconic.rst b/docs/api/pylabrobot.liconic.rst new file mode 100644 index 00000000000..100674d385b --- /dev/null +++ b/docs/api/pylabrobot.liconic.rst @@ -0,0 +1,22 @@ +.. currentmodule:: pylabrobot.liconic + +pylabrobot.liconic package +========================== + +.. currentmodule:: pylabrobot.liconic.liconic + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Liconic + +.. currentmodule:: pylabrobot.liconic.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + LiconicBackend diff --git a/docs/api/pylabrobot.mettler_toledo.rst b/docs/api/pylabrobot.mettler_toledo.rst new file mode 100644 index 00000000000..8efa5ef2424 --- /dev/null +++ b/docs/api/pylabrobot.mettler_toledo.rst @@ -0,0 +1,15 @@ +.. currentmodule:: pylabrobot.mettler_toledo + +pylabrobot.mettler_toledo package +================================= + +.. currentmodule:: pylabrobot.mettler_toledo.mettler_toledo + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MettlerToledoWXS205SDUDriver + MettlerToledoWXS205SDUScaleBackend + MettlerToledoError diff --git a/docs/api/pylabrobot.qinstruments.rst b/docs/api/pylabrobot.qinstruments.rst new file mode 100644 index 00000000000..aaa74b54363 --- /dev/null +++ b/docs/api/pylabrobot.qinstruments.rst @@ -0,0 +1,28 @@ +.. currentmodule:: pylabrobot.qinstruments + +pylabrobot.qinstruments package +=============================== + +.. currentmodule:: pylabrobot.qinstruments.bioshake + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + BioShake + BioShakeDriver + BioShakeShakerBackend + BioShakeTemperatureBackend + BioShake3000 + BioShake3000Elm + BioShake3000ElmDWP + BioShakeD30Elm + BioShake5000Elm + BioShake3000T + BioShake3000TElm + BioShakeD30TElm + BioShakeQ1 + BioShakeQ2 + Heatplate + ColdPlate diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 866bc6a0baa..eef14e56dd1 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -25,3 +25,17 @@ Subpackages pylabrobot.config pylabrobot.resources pylabrobot.utils + +Manufacturers +------------- + +.. toctree:: + :maxdepth: 1 + + pylabrobot.agilent + pylabrobot.azenta + pylabrobot.inheco + pylabrobot.liconic + pylabrobot.mettler_toledo + pylabrobot.qinstruments + pylabrobot.thermo_fisher diff --git a/docs/api/pylabrobot.thermo_fisher.rst b/docs/api/pylabrobot.thermo_fisher.rst new file mode 100644 index 00000000000..9df26886384 --- /dev/null +++ b/docs/api/pylabrobot.thermo_fisher.rst @@ -0,0 +1,114 @@ +.. currentmodule:: pylabrobot.thermo_fisher + +pylabrobot.thermo_fisher package +================================ + +Multidrop Combi +--------------- + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.multidrop_combi + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MultidropCombi + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.driver + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MultidropCombiDriver + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + MultidropCombiPeristalticDispensingBackend + +.. autoclass:: pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend.MultidropCombiPeristalticDispensingBackend.DispenseParams + :members: + +.. autoclass:: pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend.MultidropCombiPeristalticDispensingBackend.PrimeParams + :members: + +.. autoclass:: pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend.MultidropCombiPeristalticDispensingBackend.PurgeParams + :members: + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.enums + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CassetteType + DispensingOrder + PrimeMode + EmptyMode + +.. currentmodule:: pylabrobot.thermo_fisher.multidrop_combi.helpers + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + plate_to_type_index + plate_to_pla_params + + +Cytomat +------- + +.. currentmodule:: pylabrobot.thermo_fisher.cytomat.cytomat + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Cytomat + +.. currentmodule:: pylabrobot.thermo_fisher.cytomat.backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CytomatBackend + +.. currentmodule:: pylabrobot.thermo_fisher.cytomat.chatterbox + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CytomatChatterbox + +.. currentmodule:: pylabrobot.thermo_fisher.cytomat.heraeus_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + HeraeusCytomatBackend + +.. currentmodule:: pylabrobot.thermo_fisher.cytomat.constants + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CytomatType diff --git a/docs/user_guide/01_material-handling/heating_shaking/heating_shaking.md b/docs/user_guide/01_material-handling/heating_shaking/heating_shaking.md index a5b2a4f79ab..689289397d3 100644 --- a/docs/user_guide/01_material-handling/heating_shaking/heating_shaking.md +++ b/docs/user_guide/01_material-handling/heating_shaking/heating_shaking.md @@ -23,6 +23,5 @@ PyLabRobot supports the following heater shakers: ```{toctree} :maxdepth: 1 -Inheco ThermoShake Hamilton Heater Shaker ``` diff --git a/docs/user_guide/01_material-handling/heating_shaking/inheco.ipynb b/docs/user_guide/01_material-handling/heating_shaking/inheco.ipynb deleted file mode 100644 index 46bbeb2793c..00000000000 --- a/docs/user_guide/01_material-handling/heating_shaking/inheco.ipynb +++ /dev/null @@ -1,229 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Hello World, Inheco ThermoShake!\n", - "\n", - "The Inheco Thermoshake is a heater-cooler-shaker machine that enables:\n", - "- heating & cooling,\n", - "- locking & unlocking, and\n", - "- (orbital) shaking\n", - "\n", - "...of plates.\n", - "\n", - "- Temperature control = 4°C to 105°C (all variants, max. 25°C difference to RT in cooling mode)\n", - "- Variants:\n", - " - **Inheco ThermoShake RM** ([manufacturer link](https://www.inheco.com/thermoshake-classic.html))\n", - " - PLR name: `inheco_thermoshake_rm`\n", - " - Cat. no.: 7100144\n", - " - status: PLR-tested\n", - " - shaking orbit = 2.0 mm \n", - " - shaking speed = 100 - 2000 rpm\n", - " - footprint: size_x=147 mm, size_y=104 mm, size_z=116 mm\n", - " - **Inheco ThermoShake** ([manufacturer link](https://www.inheco.com/thermoshake-classic.html))\n", - " - PLR name: `inheco_thermoshake`\n", - " - Cat. no.: 7100146\n", - " - status: PLR-untested (should have the same API as RM)\n", - " - shaking orbit = 2.0 mm \n", - " - shaking speed = 100 - 2000 rpm\n", - " - footprint: size_x=147 mm, size_y=104 mm, size_z=118 mm\n", - " - **Inheco ThermoShake AC** ([manufacturer link](https://www.inheco.com/thermoshake-ac.html))\n", - " - PLR name: `inheco_thermoshake_ac`\n", - " - Cat. no.: 7100160 & 7100161\n", - " - status: PLR-untested (should have the same API as RM)\n", - " - shaking orbit = 2.0 mm \n", - " - shaking speed = 300 - 3000 rpm\n", - " - footprint: size_x=147 mm, size_y=104 mm, size_z=115.9 mm\n", - "\n", - "Check out the [Thermoshake User and installation manual](https://www.inheco.com/data/pdf/thermoshake-manual-1013-1049-33.pdf) for more information." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Setup Instructions (Physical)\n", - "\n", - "See the [Inheco TemperatureController user guide](../temperature-controllers/inheco.ipynb#setup-instructions-physical) for instructions on using multiple ThermoShakes with one control box. The instructions are the same as for the Inheco CPAC." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Usage" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.heating_shaking import HeaterShaker\n", - "from pylabrobot.temperature_controlling import InhecoTECControlBox\n", - "\n", - "control_box = InhecoTECControlBox()\n", - "await control_box.setup()" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "pylabrobot.heating_shaking.heater_shaker.HeaterShaker" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "from pylabrobot.heating_shaking import inheco_thermoshake\n", - "hs = inheco_thermoshake(\n", - " name=\"Inheco Thermoshake\",\n", - " control_box=control_box,\n", - " index=1,\n", - ")\n", - "await hs.setup()\n", - "type(hs) # pylabrobot.heating_shaking.HeaterShaker" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Temperature Control\n", - "\n", - "See the [Inheco TemperatureController user guide](../temperature-controllers/inheco.ipynb#temperature-control) for temperature control instructions. They are the same for ThermoShake as they are for the Inheco CPAC." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Shaking Control" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The {meth}`~pylabrobot.heating_shaking.heater_shaker.HeaterShaker.setup` method is used to initialize the machine. This is where the backend will connect to the scale and perform any necessary initialization.\n", - "\n", - "The {class}`~pylabrobot.heating_shaking.heater_shaker.HeaterShaker` class has a number of methods for controlling the temperature and shaking of the sample. These are inherited from the {class}`~pylabrobot.temperature_controllers.temperature_controller.TemperatureController` and {class}`~pylabrobot.shakers.shaker.Shaker` classes.\n", - "\n", - "- {meth}`~pylabrobot.heating_shaking.heater_shaker.HeaterShaker.set_temperature`: Set the temperature of the module.\n", - "- {meth}`~pylabrobot.heating_shaking.heater_shaker.HeaterShaker.get_temperature`: Get the current temperature of the module.\n", - "- {meth}`~pylabrobot.heating_shaking.heater_shaker.HeaterShaker.shake`: Set the shaking speed of the module.\n", - "- {meth}`~pylabrobot.heating_shaking.heater_shaker.HeaterShaker.stop_shaking`: Stop the shaking of the module." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Shake indefinitely:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.shake(speed=100) # speed in rpm" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.stop_shaking() # Stop shaking" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Shake for 10 seconds:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.shake(\n", - " speed=100,\n", - " duration=10\n", - ") # speed in rpm, duration in seconds" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Closing Connection to Machine" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await hs.stop()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Using Multiple Inheco Devices" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "See the [Inheco TemperatureController user guide](../temperature-controllers/inheco.ipynb#using-multiple-inheco-devices) for instructions on using multiple ThermoShakes with one control box. The instructions are the same as for the Inheco CPAC." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/01_material-handling/sealers/a4s.ipynb b/docs/user_guide/01_material-handling/sealers/a4s.ipynb deleted file mode 100644 index df38ffcc760..00000000000 --- a/docs/user_guide/01_material-handling/sealers/a4s.ipynb +++ /dev/null @@ -1,285 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "18708b66", - "metadata": {}, - "source": [ - "# Azenta a4S\n", - "\n", - "| Summary | Photo |\n", - "|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|\n", - "| - [OEM Link](https://www.azenta.com/products/automated-roll-heat-sealer-formerly-a4s)
- **Communication Protocol / Hardware**: Serial / USB-A
- **Communication Level**: Firmware (documentation shared by OEM)
- **Sealing Method**: Thermal (heat + pressure)
- **Compressed Air Required?**: No
- **Typical Seal Time**: ~7 seconds

The a4S has only 2 programmatically-accessible action parameters for sealing:
- temperature
- sealing duration | ![quadrants](img/azenta_a4s.png) |\n" - ] - }, - { - "cell_type": "markdown", - "id": "adb29364", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "34531f2c", - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "markdown", - "id": "0e8abc45", - "metadata": {}, - "source": [ - "Identify your control PC's port to your a4S sealer and instantiate the `Sealer` frontend called `a4s`:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "363b8144", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.sealing import a4s\n", - "\n", - "s = a4s(port=\"/dev/tty.usbserial-0001\") # This is a predifned Sealer object with the A4SBackend\n", - "\n", - "# You can also use the Sealer class directly, e.g.:\n", - "# from pylabrobot.sealing.sealer import Sealer\n", - "# from pylabrobot.sealing.a4s_backend import A4SBackend\n", - "# s = Sealer(backend=A4SBackend(port=\"/dev/tty.usbserial-0001\"))\n", - "\n", - "type(s)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "30720acb", - "metadata": {}, - "outputs": [], - "source": [ - "await s.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "65555028", - "metadata": {}, - "source": [ - "```{note}\n", - "When the a4S is first powered on, it will open its loading tray - this means the **machine default state is open**!\n", - "\n", - "If this is the first time you are using the a4S, follow the OEM’s instructions to load a foil/film roll using the required metal film loading tool.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "7d2e9ed2", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Usage\n", - "\n", - "### Sealing\n", - "\n", - "The a4S firmware enables sealing with just one simple command:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c0834a6e", - "metadata": {}, - "outputs": [], - "source": [ - "await s.seal(\n", - " temperature=180, # degrees Celsius\n", - " duration=5, # sec\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "ce638f41", - "metadata": {}, - "source": [ - "This command will...\n", - "1. set the `temperature`\n", - "2. wait until temperature is reached (!)\n", - "3. move the plate into the machine / close the loading tray\n", - "4. cut the film off its roll (!!)\n", - "5. perform sealing of film onto the plate for the specified `duration`\n", - "6. move the plate out of the machine / open the loading tray" - ] - }, - { - "cell_type": "markdown", - "id": "9c4d21b7", - "metadata": {}, - "source": [ - "### Pre-set Temperature\n", - "\n", - "To accelerate the sealing step you can pre-set the temperature of the sealer by using the `set_temperature` method.\n", - "The temperature is set in degrees Celsius." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "97dbe69e", - "metadata": {}, - "outputs": [], - "source": [ - "await s.set_temperature(170)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bb2de16b", - "metadata": {}, - "outputs": [], - "source": [ - "await s.get_temperature()" - ] - }, - { - "cell_type": "markdown", - "id": "c4be6218", - "metadata": {}, - "source": [ - "---\n", - "### Close and Open of the Loading Tray\n", - "\n", - "The a4S does empower standalone closing and opening of the loading tray.\n", - "However, there is no conceivable reason to do so when one considers the issues this creates:\n", - "\n", - "The default position of the machine's loading tray is open.\n", - "If one executes..." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e23acc3d", - "metadata": {}, - "outputs": [], - "source": [ - "await s.close()" - ] - }, - { - "cell_type": "markdown", - "id": "c7a70d7e", - "metadata": {}, - "source": [ - "...this not only closes the loading tray but **also cuts the film/foil that is currently loaded - without performing a sealing action!**\n", - "\n", - "```{warning}\n", - "This means a **single leaf of film will fall onto the loading tray** (or on the top of a plate located on the loading tray).\n", - "```\n", - "\n", - "(This is a mechanical constraint of the a4S' design:\n", - "\n", - "Without active motors turning the film roll into the opposite direction during an `await s.close()` command the film inside the machine would be pushed inwards and buckle.\n", - "This could lead to multiple problems, including potential sticking of the film to hot internals.\n", - "As a result, the cutting of the film during close is an inbuilt, mechanical safety feature [to our knowledge])\n", - "\n", - "When executing..." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8656ef6", - "metadata": {}, - "outputs": [], - "source": [ - "await s.open()" - ] - }, - { - "cell_type": "markdown", - "id": "8554a66c", - "metadata": {}, - "source": [ - "...the single leaf of film will then require manual removal.\n", - "\n", - "(Except if you are using some advanced soft-robotics arm that can handle films/foils 🐙👀)\n", - "\n", - "```{note}\n", - "It is possible that this cutting of film during a closing procedure disconnects the film roll with the internals.\n", - "If this happens you have to manually re-spool the film roll before you can continue.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "39a0d84f", - "metadata": {}, - "source": [ - "---\n", - "\n", - "### Querying Machine Status\n", - "\n", - "The a4S has advanced features that are available by calling the frontend's (`Sealer`/`a4s`) backend (`A4SBackend`) directly." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b85f0c49", - "metadata": {}, - "outputs": [], - "source": [ - "status = await s.backend.get_status()\n", - "print(\"current_temperature: \", status.current_temperature)\n", - "print(\"system_status: \", status.system_status)\n", - "print(\"heater_block_status: \", status.heater_block_status)\n", - "print(\"error_code: \", status.error_code)\n", - "print(\"warning_code: \", status.warning_code)\n", - "print(\"sensor_status: \")\n", - "print(\" shuttle_middle_sensor: \", status.sensor_status.shuttle_middle_sensor)\n", - "print(\" shuttle_open_sensor: \", status.sensor_status.shuttle_open_sensor)\n", - "print(\" shuttle_close_sensor: \", status.sensor_status.shuttle_close_sensor)\n", - "print(\" clean_door_sensor: \", status.sensor_status.clean_door_sensor)\n", - "print(\" seal_roll_sensor: \", status.sensor_status.seal_roll_sensor)\n", - "print(\" heater_motor_up_sensor: \", status.sensor_status.heater_motor_up_sensor)\n", - "print(\" heater_motor_down_sensor: \", status.sensor_status.heater_motor_down_sensor)\n", - "print(\"remaining_time: \", status.remaining_time)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env (3.10.15)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/01_material-handling/sealers/sealers.md b/docs/user_guide/01_material-handling/sealers/sealers.md index 3283f401992..f3a8a68a930 100644 --- a/docs/user_guide/01_material-handling/sealers/sealers.md +++ b/docs/user_guide/01_material-handling/sealers/sealers.md @@ -55,5 +55,4 @@ They do **not** use heat, making them faster and simpler for certain workflows. :maxdepth: 1 :hidden: -Azenta a4S ``` diff --git a/docs/user_guide/01_material-handling/storage/inheco/incubator_shaker.ipynb b/docs/user_guide/01_material-handling/storage/inheco/incubator_shaker.ipynb deleted file mode 100644 index 34425a463ed..00000000000 --- a/docs/user_guide/01_material-handling/storage/inheco/incubator_shaker.ipynb +++ /dev/null @@ -1,1191 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "01bd78dc-183e-45fe-a3b0-59c8666b4f14", - "metadata": {}, - "source": [ - "# Inheco Incubator (Shaker)\n", - "\n", - "| Summary | Image |\n", - "|------------|--------|\n", - "|
  • OEM Link
  • Communication Protocol / Hardware: Serial / USB-A/B
  • Communication Level: Firmware (documentation shared by OEM)
  • Same command set for:
    • Incubator “MP”
    • Incubator “DWP”
    • Incubator Shaker “MP”
    • Incubator Shaker “DWP”
  • VID:PID 0403:6001
  • Takes in a single plate via a loading tray, heats it to the set temperature, and shakes it to the set RPM.
|
![shaker](img/inheco_incubator_shaker_mp_dwp.png)
Figure: Inheco Incubator Shaker MP & DWP models
|\n" - ] - }, - { - "cell_type": "markdown", - "id": "70f74446-c274-4803-a6ce-6c9c2d7d3bba", - "metadata": {}, - "source": [ - "## About the Machine(s)\n", - "\n", - "Inheco incubator shakers are modular machines used for plate storage, temperature control and shaking.\n", - "They differentiate themselves:\n", - "- **heater shakers** ... heat a material on which a plate is being placed; open-access; non-uniform temperature distribution around the plate; enables shaking of plate.\n", - "- **incubator shakers** ... an enclosed chamber that is being heated and houses a plate; plate access is controlled via a loading tray and a door; *highly uniform temperature distribution around the plate*; enables shaking of plate.\n", - "\n", - "The Inheco incubator devices come in 4 versions, dependent on (1) whether they provide a shaking feature & (2) the size of plates they accept:\n", - "\n", - "\n", - "| **RTS Code** | **Shaking Feature** | **Plate Format** | **Device Identifier** | **Typical Model** |\n", - "|:-------------:|:--------------:|:----------------:|:----------------------|:------------------|\n", - "| `0` | ❌ No | MP (Microplate) | `incubator_mp` | INHECO Incubator MP | \n", - "| `1` | ✅ Yes | MP (Microplate) | `incubator_shaker_mp` | INHECO Incubator Shaker MP | \n", - "| `2` | ❌ No | DWP (Deepwell Plate) | `incubator_dwp` | INHECO Incubator DWP | \n", - "| `3` | ✅ Yes | DWP (Deepwell Plate) | `incubator_shaker_dwp` | INHECO Incubator Shaker DWP | \n", - "\n", - "\n", - "```{note}\n", - "Note: All 4 machines can be controlled with the same PyLabRobot Backend, called `InhecoIncubatorShakerBackend`!\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "42739583-9f29-4063-983d-18dcdfea61ba", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)" - ] - }, - { - "cell_type": "markdown", - "id": "38dcc34a", - "metadata": {}, - "source": [ - "![copy-me](img/inheco_incubator_shaker_physical_setup_overview.png)" - ] - }, - { - "cell_type": "markdown", - "id": "e0f0ef32-566b-4fa3-b358-db2a703957de", - "metadata": {}, - "source": [ - "To facilitate integration, multiple devices can be placed on top of each other to form an Incubator Shaker Stack (see infographic above), but care has to be taken to not overstrain the connections:\n", - "\n", - "Each of the 4 different shaker types requires a different amount of power.\n", - "An easier way to identify the configurations possible is to think of \"incubator power credits\" - **no stack must exceed 5 power credits** (see User and Installation Manual):\n", - "\n", - "1. An \"incubator MP\" -> 1 \"incubator power credits\" -> 5 units can be stacked on top of each other.\n", - "2. An \"incubator DWP\" -> 1.25 \"incubator power credits\" -> 4 units.\n", - "3. An \"incubator shaker MP\" -> 1.6 \"incubator power credits\" -> 3 units\n", - "4. An \"incubator shaker DWP\" -> 2.5 \"incubator power credits\" -> 2 units\n", - "\n", - "However, the machines in a single stack can be of any of the 4 types.\n", - "This means you could create stacks of: \n", - "- 2x \"incubator DWP\" (1.25 credits) + 1x \"incubator shaker DWP\" (2.5 credits)\n", - "- 3x \"incubator MP\" (1 credits) + 1x \"incubator shaker MP\" (1.6 credits) [shown in the infographic above]\n", - "\n", - "When a stack would exceed more than 5 \"incubator power credits\", you **must build multiple stacks** (ask your Inheco sales representative if you are unsure before trying this out).\n", - "\n", - "The benefit of this setup is that only **one** power cable and only **one** USB cable have to be plugged into the machine at the very bottom of a machine (i.e. stack index 0).\n", - "Machines above the bottom one only need to be connected with the machine below it using the 15-pin SUB-D connectors that come with each machine when bought from Inheco.\n", - "\n", - "```{note}\n", - "Note: In PyLabRobot, the stack is the central control element and is controlled via its own instance of the `InhecoIncubatorShakerStackBackend`.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "69f8951f", - "metadata": {}, - "source": [ - "| Explanation | Image |\n", - "|------------|--------|\n", - "|
To connect an InhecoIncubatorShakerStackBackend you must set the DIP switch identifier on the back of the bottom machine:
  • located on the back of the bottom machine,
  • defines the DIP switch configuration for the entire stack.

Setting the DIP switch to generate a machine address

Each machine has a 4-pin DIP switch. Each pin can be UP (0) or DOWN (1).

Note: the two pins to the left of the DIP switch are not part of the addressing and should remain in the DOWN position.

This forms a 4-bit binary address:
  • All pins at 0 → binary 0 0 0 0 → decimal 0
  • All pins at 1 → binary 1 1 1 1 → decimal 15 (24-1)
This address is crucial for generating valid communication commands for your Inheco stack.
|
![dip switches](img/inheco_incubator_shaker_dip_switch_addressing.png)
Figure: DIP switch layout to generate different identifiers/addresses
|\n" - ] - }, - { - "cell_type": "markdown", - "id": "d0d6256e-673a-4979-88cc-d07959ce92ea", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)\n", - "\n", - "After the two cables have been connected to the bottom-most Inheco Incubator Shaker, you have to...\n", - "1. instantiate the `InhecoIncubatorShakerStackBackend` and give it the correct `dip_switch_id` & `stack_index`, and\n", - "2. create a `IncubatorShakerStack` frontend and give it the new backend instance.\n", - "\n", - "The \"stack\" is the central interface to all units in it.\n", - "The stack automatically identifies all units inside it (including their type), and will create both the correct connection and a physical instance for it.\n", - "\n", - "```{note}\n", - "Before a connection has been established the incubator shaker's front LED blinks.\n", - "After the connection has successfully been made, the LED will continuously be on.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c9acf6e1-2465-42fd-bad8-f6d3fc052e97", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.storage.inheco import IncubatorShakerStack, InhecoIncubatorShakerStackBackend\n", - "\n", - "import asyncio # only needed for examples in this tutorial, optional for your purposes\n", - "import time # only needed for examples in this tutorial, optional for your purposes" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "bb6e529f-3bb3-46de-87af-5931269b04e4", - "metadata": {}, - "outputs": [], - "source": [ - "iis_stack_backend = InhecoIncubatorShakerStackBackend(dip_switch_id = 2)\n", - "\n", - "iis_stack = IncubatorShakerStack(backend=iis_stack_backend)\n", - "\n", - "await iis_stack.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "c2c7002f-a11b-402e-8100-5d9eb528a1f0", - "metadata": {}, - "source": [ - "```{note}\n", - "If you are interested in seeing information about the machine you are connecting to, you can set the `.setup()` optional argument `verbose` to `True`:\n", - "1. serial port used for connection\n", - "2. DIP switch ID used and verified\n", - "3. number of units identified in the stack\n", - "4. composition (index and type of units) of the stack \n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "0a17aadb-8373-4e24-9678-7c4453fc8661", - "metadata": {}, - "source": [ - "## Usage: Controlling Individual Units" - ] - }, - { - "cell_type": "markdown", - "id": "92e7123a-8aa7-41b7-baa9-9765bddaffe9", - "metadata": {}, - "source": [ - "### Addressing Units & Sensing Plate Presence\n", - "\n", - "The stack interface enables fast, direct access to any machine in a stack.\n", - "\n", - "Every Inheco incubator (shaker) contains an internal, reflection-based plate sensor.\n", - "(This is very useful e.g. when someone has forgotten their plate in the incubator 👀)\n", - "\n", - "Let's use this as an example of how you can address different units in the stack individually:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "0585bf87-7d00-4d1e-9ce3-f58617d66961", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "2" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "iis_stack.num_units" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7642febc-5a5e-4e17-be6d-8a75fae7a1f4", - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0 False\n", - "1 False\n" - ] - } - ], - "source": [ - "for idx in range(iis_stack.num_units):\n", - " plate_presence_check = await iis_stack[idx].request_plate_in_incubator()\n", - " print(idx, plate_presence_check)" - ] - }, - { - "cell_type": "markdown", - "id": "051629ac", - "metadata": {}, - "source": [ - "Option 2: Addressing individual units by calling the stack backend with the correct stack_index" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "04fa7214-fb34-4230-8fb4-f20e66a8e476", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0 False\n", - "1 False\n" - ] - } - ], - "source": [ - "for idx in range(iis_stack.num_units):\n", - " plate_presence_check = await iis_stack.backend.request_plate_in_incubator(\n", - " stack_index=idx\n", - " )\n", - " print(idx, plate_presence_check)" - ] - }, - { - "cell_type": "markdown", - "id": "59e6c797", - "metadata": {}, - "source": [ - "Option 3: Storing each unit as a handy variable" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "87ed2d05-620a-4e8a-9578-bd5d27a81e2a", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "False False\n" - ] - } - ], - "source": [ - "incubator_shaker_0 = iis_stack[0]\n", - "plate_presence_check_0 = await incubator_shaker_0.request_plate_in_incubator()\n", - "\n", - "incubator_shaker_1 = iis_stack[1]\n", - "plate_presence_check_1 = await incubator_shaker_1.request_plate_in_incubator()\n", - "\n", - "print(plate_presence_check_0, plate_presence_check_1)" - ] - }, - { - "cell_type": "markdown", - "id": "dea2645e-5426-43c0-9511-35b30aa290cb", - "metadata": {}, - "source": [ - "We usually use the direct indexing of the frontend method but it is up to you to choose.\n", - "e.g.: storing of units in separate variables can be very useful when using many stacks." - ] - }, - { - "cell_type": "markdown", - "id": "a30de061-fc3f-434c-882e-4972c8404479", - "metadata": {}, - "source": [ - "### Using Loading Tray" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e6009273-a626-4de9-a9db-0f0de8021a0e", - "metadata": {}, - "outputs": [], - "source": [ - "for idx in range(iis_stack.num_units):\n", - " await iis_stack[idx].open()\n", - " await asyncio.sleep(2)\n", - " await iis_stack[idx].close()" - ] - }, - { - "cell_type": "markdown", - "id": "7d86ace1-7522-4630-ad10-6f4b944fbfc9", - "metadata": {}, - "source": [ - "```{warning}\n", - "**On parallelization of commands to machines in the same incubator shaker stack**\n", - "\n", - "Each machine in the same stack communicates via the same USB(-A to -B) cable.\n", - "As a result, if you send multiple commands at the same time, they will be queued and executed one after another.\n", - "\n", - "This means you cannot open all incubator shakers in the same stack at the same time.\n", - "\n", - "However, if you arrange your Inheco Incubators into different stacks this should still be possible.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "6aaef05a-2cb9-4b12-bf86-ae6d63665036", - "metadata": {}, - "source": [ - "### Temperature Control" - ] - }, - { - "cell_type": "markdown", - "id": "7e28b749", - "metadata": {}, - "source": [ - "Show current temperature in °C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8da2cf9-cbeb-4202-8ef7-2cc583ce16c4", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "20.1\n", - "23.6\n" - ] - } - ], - "source": [ - "for idx in range(iis_stack.num_units):\n", - " current_temp = await iis_stack[idx].get_temperature()\n", - " print(current_temp)" - ] - }, - { - "cell_type": "markdown", - "id": "e35729c9", - "metadata": {}, - "source": [ - "Time how long the machine takes to reach target temperature using standard Python - no need to re-invent the wheel" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "92fe00a9-9546-4d11-9a3c-c644188ea0c0", - "metadata": {}, - "outputs": [], - "source": [ - "target_temperature = 37\n", - "\n", - "await iis_stack[0].start_temperature_control(target_temperature)\n", - "\n", - "start_time = time.time()" - ] - }, - { - "cell_type": "markdown", - "id": "d8cf8808", - "metadata": {}, - "source": [ - "Quick check of how the temperature increases for 5 sec" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b635baae-0cc9-4d10-8644-c377f94656b9", - "metadata": { - "scrolled": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "20.3\n", - "20.7\n", - "21.6\n", - "22.6\n", - "23.5\n" - ] - } - ], - "source": [ - "for x in range(5):\n", - " current_temp = await iis_stack[0].get_temperature(sensor=\"main\")\n", - " print(current_temp)\n", - "\n", - " time.sleep(1)" - ] - }, - { - "cell_type": "markdown", - "id": "438e19d7-714d-46f3-8a9e-f00798ca9893", - "metadata": {}, - "source": [ - "| Explanation | Image |\n", - "|------------|--------|\n", - "|

The Inheco Incubator (Shaker) contains three independent temperature sensors:

  1. main sensor — close to the door/front, inside the machine
  2. validation sensor — back, inside the machine
  3. boost sensor — on heating foil, inside the machine

By default, iis_stack[0].get_temperature()’s argument is set to sensor=\"main\".
This can be changed to any of the following:

  • \"main\"
  • \"dif\"
  • \"boost\"
  • \"mean\" — takes all three sensors’ measurements and returns their geometric mean
|
![sensor positions](img/inheco_incubator_shaker_t_sensor_positioning.png)
Figure: Inheco Incubator Shaker Temperature Sensor Positioning
|\n" - ] - }, - { - "cell_type": "markdown", - "id": "aebaf1c5", - "metadata": {}, - "source": [ - "Wait until target temperature has been reached:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8c5caa89-e5c5-49de-996f-a13cd790aa61", - "metadata": {}, - "outputs": [], - "source": [ - "temp_reached = await iis_stack[0].wait_for_temperature(\n", - " sensor = \"mean\",\n", - " tolerance = 0.1, # ℃ - default: 0.2\n", - " interval_s = 0.2, # sec - default: 0.5\n", - " show_progress_bar = True # default: False\n", - ")\n", - "\n", - "elapsed_time = time.time() - start_time\n", - "\n", - "print(f\"\\ntime taken to reach target temperature {target_temperature}°C: {round(elapsed_time, 1)} sec\")" - ] - }, - { - "cell_type": "markdown", - "id": "55235ece", - "metadata": {}, - "source": [ - "Simple stopping of temperature control without stopping (i.e. breaking the connection) the machine itself:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "88e17cd9-de42-4f6e-9db0-b74ad74e4a85", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack[0].stop_temperature_control()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b5a0ff93-ee2e-4049-86f7-59815163d6f2", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "False" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack[0].is_temperature_control_enabled()" - ] - }, - { - "cell_type": "markdown", - "id": "58c0aa31-9b6b-426f-a038-cdb80614c005", - "metadata": {}, - "source": [ - "### Shaking Control\n", - "\n", - "Only Incubator \"Shakers\" can use shaking commands.\n", - "\n", - "During `.setup()` the machine will check whether it is an `incubator_shaker` (\"MP\" or \"DWP\") and the Python backend only allows shaking commands being sent to the machine if it is an `incubator_shaker`, i.e. the following commands will not work if you have pure incubators." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fdac2882-59c2-4531-a115-97a644765476", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack[0].shake(rpm=800)\n", - "\n", - "await asyncio.sleep(5)\n", - "\n", - "await iis_stack[0].stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "id": "fc1da4bf-452e-4ead-bd8a-64d219449a64", - "metadata": {}, - "source": [ - "Inheco incubator shakers support precise, programmable motion in both the **X** and **Y** axes.\n", - "The resulting shaking pattern is defined by five parameters:\n", - "\n", - "- **Amplitude in X** (`Aₓ`, 0–3 mm)\n", - "- **Amplitude in Y** (`Aᵧ`, 0–3 mm)\n", - "- **Frequency in X** (`fₓ`, 6.6–30.0 Hz)\n", - "- **Frequency in Y** (`fᵧ`, 6.6–30.0 Hz)\n", - "- **Phase shift** (`φ`, the angular offset between X and Y motion, in degrees)\n", - "\n", - "Different combinations of these parameters produce circular, linear, elliptical, or\n", - "figure-eight movement paths.\n", - "\n", - "---\n", - "\n", - "#### Predefined Shaking Patterns in PyLabRobot\n", - "\n", - "To simplify configuration, PyLabRobot provides predefined motion presets that map common use cases to specific parameter combinations:\n", - "\n", - "| Pattern | Description | Parameter relationship | Required speed attribute |\n", - "|----------|--------------|------------------------|---------------------------|\n", - "| `orbital` | Circular shaking | `Aₓ = Aᵧ`, `φ = 90°`, `fₓ = fᵧ` | `rpm` |\n", - "| `elliptical` | Elliptical motion | `Aₓ ≠ Aᵧ`, `φ = 90°`, `fₓ = fᵧ` | `rpm` |\n", - "| `figure_eight` | Figure-eight (Lissajous) motion | `Aₓ ≈ Aᵧ`, `φ = 90°`, `fᵧ = 2 fₓ` | `rpm` |\n", - "| `linear_x` | Linear motion along X | `Aᵧ = 0` | `frequency_hz` |\n", - "| `linear_y` | Linear motion along Y | `Aₓ = 0` | `frequency_hz` |\n", - "\n", - "```{note}\n", - "The default behaviour of `.shake()` uses...\n", - "- an orbital shaking pattern,\n", - "- x amplitude = 3 mm,\n", - "- y amplitude = 3 mm.\n", - "\n", - "(see “Simplest usage” example above)\n" - ] - }, - { - "cell_type": "markdown", - "id": "637c7550", - "metadata": {}, - "source": [ - "Orbital shaking example with modified amplitudes" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "21ef6992-fdd2-47f7-b739-0de4cb1022e0", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack[0].shake(\n", - " pattern=\"orbital\",\n", - " rpm=800,\n", - " amplitude_x_mm=2.0,\n", - " amplitude_y_mm=2.0\n", - ")\n", - "\n", - "await asyncio.sleep(5)\n", - "\n", - "await iis_stack[0].stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "id": "2a9b5212", - "metadata": {}, - "source": [ - "Elliptical shaking example with modified amplitudes:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8b6d28a3-6c02-47cd-a5e0-1e01cc35e4d0", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack[0].shake(\n", - " pattern=\"elliptical\",\n", - " rpm=800,\n", - " amplitude_x_mm=2.5,\n", - " amplitude_y_mm=2.5\n", - ")\n", - "\n", - "await asyncio.sleep(5)\n", - "\n", - "await iis_stack[0].stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "id": "f1a72cea", - "metadata": {}, - "source": [ - "Figure-eight shaking example:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bd1b713c-0c1f-4e6c-81b0-88cee4ba8719", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack[0].shake(\n", - " pattern=\"figure_eight\",\n", - " rpm=400,\n", - ")\n", - "\n", - "await asyncio.sleep(5)\n", - "\n", - "await iis_stack[0].stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "id": "9eb382d9-3432-4d0e-9514-02f25eb7ef68", - "metadata": {}, - "source": [ - "If you feel adventurous, see the math that goes into the calculation of different shaking patterns here:" - ] - }, - { - "cell_type": "markdown", - "id": "97014c29-c023-4259-aa3f-1b7fa85c9c24", - "metadata": {}, - "source": [ - "
\n", - "📘 How PyLabRobot Implements Inheco Shaking Patterns (Mathematical Overview)\n", - "\n", - "Inheco incubator shakers move a plate by oscillating the platform in two directions — **X** and **Y** — at programmable amplitudes, frequencies, and phase offsets.\n", - "\n", - "---\n", - "\n", - "**The Core Equations**\n", - "\n", - "The motion of the platform is described by two sinusoidal functions:\n", - "\n", - "\\[\n", - "\\begin{aligned}\n", - "x(t) &= Aₓ \\sin(2\\pi fₓ t) \\\\\n", - "y(t) &= Aᵧ \\sin(2\\pi fᵧ t + φ)\n", - "\\end{aligned}\n", - "\\]\n", - "\n", - "Where:\n", - "\n", - "| Symbol | Meaning | Example |\n", - "|:--|:--|:--|\n", - "| `Aₓ`, `Aᵧ` | Amplitudes (mm) — how far the plate moves in X and Y | 2.5 mm |\n", - "| `fₓ`, `fᵧ` | Frequencies (Hz) — how fast each axis oscillates | 10 Hz, 20 Hz |\n", - "| `φ` | Phase shift (°) — timing offset between X and Y | 0°, 90°, 180° |\n", - "\n", - "Each axis moves smoothly back and forth like a spring. \n", - "When these two motions combine, they trace elegant paths such as circles, ellipses, or figure-eights.\n", - "\n", - "---\n", - "\n", - "**Pattern Intuition**\n", - "\n", - "Different shaking patterns are created by adjusting the relationships between these parameters:\n", - "\n", - "| Pattern | Conditions | Description |\n", - "|:--|:--|:--|\n", - "| **Linear X** | `Aᵧ = 0` | Motion only along X (back-and-forth line) |\n", - "| **Linear Y** | `Aₓ = 0` | Motion only along Y |\n", - "| **Orbital** | `Aₓ = Aᵧ`, `fₓ = fᵧ`, `φ = 90°` | Perfect circular motion |\n", - "| **Elliptical** | `Aₓ ≠ Aᵧ`, `fₓ = fᵧ`, `φ = 90°` | Elongated circle (ellipse) |\n", - "| **Figure-Eight (Lissajous)** | `Aₓ ≈ Aᵧ`, `fᵧ = 2 fₓ`, `φ = 90°` | Double-loop path shaped like ∞ |\n", - "\n", - "---\n", - "\n", - "**Example: Figure-Eight Motion**\n", - "\n", - "In firmware terms:\n", - "\n", - "SSP20,20,100,200,90\n", - "ASE1\n", - "\n", - "\n", - "corresponds to:\n", - "\n", - "- `Aₓ = Aᵧ = 2.0 mm`\n", - "- `fₓ = 10.0 Hz`\n", - "- `fᵧ = 20.0 Hz`\n", - "- `φ = 90°`\n", - "\n", - "This combination makes the platform’s Y motion twice as fast as its X motion — \n", - "the resulting path is a **Lissajous figure**, visually resembling a “figure-8”.\n", - "\n", - "---\n", - "\n", - "**Why This Matters**\n", - "\n", - "By controlling these parameters precisely:\n", - "- The **mixing efficiency** can be tuned to the liquid’s viscosity.\n", - "- The **path geometry** affects shear stress and aeration.\n", - "- **Repeatable motion profiles** ensure reproducibility across runs.\n", - "\n", - "Understanding this relationship helps you select the right pattern\n", - "(`orbital`, `elliptical`, `figure_eight`, etc.) for your experiment.\n", - "\n", - "
\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "id": "d18c1605-e179-4b89-932c-a1d1fd00ae10", - "metadata": {}, - "source": [ - "### Empowerment Showcase\n", - "\n", - "With control of multiple single incubator shakers a whole array of complex experimental & optimisation processes is possible.\n", - "\n", - "This PyLabRobot integration aims to make these machine powers as accessible as possible.\n", - "\n", - "One still relatively simple example:\n", - "Parallelize shaking of different incubators with different shaking + temperature conditions ... did someone say \"Design of Experiments\" 👀📊" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9016a568-3e26-4041-95cf-149d6fbe9bd6", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Waiting for target temperature 29.00 °C...\n", - "\n", - "[████████████████████████████████████----] 29.20 °C (Δ=0.20 °C | ETA: 3.0s)\n", - "[OK] Target temperature reached.\n", - "Waiting for target temperature 37.00 °C...\n", - "\n", - "[█████████████---------------------------] 36.87 °C (Δ=0.13 °C | ETA: 4.3s)\n", - "[OK] Target temperature reached.\n" - ] - } - ], - "source": [ - "await iis_stack[0].start_temperature_control(29)\n", - "await iis_stack[1].start_temperature_control(37)\n", - "\n", - "await iis_stack[0].wait_for_temperature(sensor=\"mean\", show_progress_bar=True)\n", - "await iis_stack[1].wait_for_temperature(sensor=\"mean\", show_progress_bar=True)\n", - "\n", - "\n", - "await iis_stack[0].shake(\n", - " pattern=\"orbital\",\n", - " rpm=500,\n", - ")\n", - "\n", - "await iis_stack[1].shake(\n", - " pattern=\"figure_eight\",\n", - " rpm=800,\n", - ")\n", - "\n", - "await asyncio.sleep(10)\n", - "\n", - "await iis_stack[0].stop_temperature_control()\n", - "await iis_stack[1].stop_temperature_control()\n", - "\n", - "await iis_stack[0].stop_shaking()\n", - "await iis_stack[1].stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "id": "4db6a1e0-ed03-4359-9ab8-e19246d082cf", - "metadata": {}, - "source": [ - "### Self Test / Maintenance (PLR beta)\n", - "\n", - "The Inheco firmware provides a \"self-test\" which checks the drawer, temperature and shaking features.\n", - "This test can take up to 5 min.\n", - "\n", - "The test *must be* performed without a plate in the incubator.\n", - "\n", - "It generates a binary code in which each position represents a machine subsystem:\n", - "- Bit 0: Drawer\n", - "- Bit 1: Homogeneity Sensor 3 versus Sensor 1 (>2 K)\n", - "- Bit 2: Homogeneity Sensor 2 versus Sensor 1 (>2 K)\n", - "- Bit 3: Sensor 1 doesn’t reach Target Temperature after 130 sec.\n", - "- Bit 4: Y-Amplitude Shaker\n", - "- Bit 5: X-Amplitude Shaker\n", - "- Bit 6: Phase Shift Shaker\n", - "- Bit 7: Y-Frequency Shaker\n", - "- Bit 8: X-Frequency Shaker\n", - "- Bit 9: Line Boost-Heater broken\n", - "- Bit 10: Line Main-Heater broken\n", - "\n", - "A `0` means no error has been found for that subsystem, and a `1` means there is a hardware fault." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1c46a480-6d0f-4792-b388-12ce9c3513f6", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{\n", - " \"drawer_error\": False,\n", - " \"homogeneity_sensor_3_vs_1_error\": False,\n", - " \"homogeneity_sensor_2_vs_1_error\": False,\n", - " \"sensor_1_target_temp_error\": False,\n", - " \"y_amplitude_shaker_error\": False,\n", - " \"x_amplitude_shaker_error\": False,\n", - " \"phase_shift_shaker_error\": False,\n", - " \"y_frequency_shaker_error\": False,\n", - " \"x_frequency_shaker_error\": False,\n", - " \"line_boost_heater_broken\": False,\n", - " \"line_main_heater_broken\": False,\n", - "}\n" - ] - }, - "execution_count": 19, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack[0].perform_self_test()" - ] - }, - { - "cell_type": "markdown", - "id": "a2d15a39-a2e4-4d7e-ac9a-5b93770bfa9b", - "metadata": {}, - "source": [ - "This is a beta feature in PyLabRobot and we will verify the interpretation with the PyLabRobot supporting OEM, Inheco - all our machines appear to be fully functional, i.e. we couldn't check whether a faulty machine will correctly be flagged by this self-test." - ] - }, - { - "cell_type": "markdown", - "id": "e9c95159-484c-4a21-9cdb-43c4ee017bbe", - "metadata": {}, - "source": [ - "---\n", - "## Usage: Master Control via the Stack Frontend 🦾\n", - "\n", - "Even though loops make setting temperatures fast and efficient, we found it is too much code.\n", - "\n", - "This is why we enabled the frontend to have \"master control commands\" for all units in a stack." - ] - }, - { - "cell_type": "markdown", - "id": "f81a3965-280f-4163-8fae-53db746e6c62", - "metadata": {}, - "source": [ - "### Querying Statuses" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9d9a782b-49f0-4c5f-8c33-504c44ccc289", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: 'closed', 1: 'closed'}" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.request_loading_tray_states()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "445accca-0dc4-4713-bbe5-4212ff8741d8", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: False, 1: False}" - ] - }, - "execution_count": 21, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.request_temperature_control_states()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "96a443ac-dfb8-4744-9f42-14e74c84f333", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: False, 1: False}" - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.request_shaking_states()" - ] - }, - { - "cell_type": "markdown", - "id": "a1660de6-b823-45ca-ada3-916c2e17d571", - "metadata": {}, - "source": [ - "### Master Commands - Loading Trays" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1c71f19f-10f5-4a38-9dad-b87967b89220", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: 'open', 1: 'open'}" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.open_all()\n", - "\n", - "await iis_stack.request_loading_tray_states()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "57fe0f47-f2ab-44db-bc0f-5cbea1f11e2c", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: 'closed', 1: 'closed'}" - ] - }, - "execution_count": 24, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.close_all()\n", - "\n", - "await iis_stack.request_loading_tray_states()" - ] - }, - { - "cell_type": "markdown", - "id": "6660d186-971a-42dc-927f-52b1689de27c", - "metadata": {}, - "source": [ - "### Master Commands - Temperature Control" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d534ff6a-7b7b-45f7-aa6a-07c0387f286f", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: 37.8, 1: 34.4}" - ] - }, - "execution_count": 25, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.start_all_temperature_control(target_temperature=37)\n", - "\n", - "await asyncio.sleep(10)\n", - "\n", - "await iis_stack.get_all_temperatures()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7e98bbf3-aed4-4ae8-a888-9c588d5189a2", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack.stop_all_temperature_control()" - ] - }, - { - "cell_type": "markdown", - "id": "71fb31e4-9cd5-4f4b-8bdb-2bb1e6e2835c", - "metadata": {}, - "source": [ - "### Master Commands - Shaking Control" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "163db59d-3ae2-4dec-880a-53b323ca00bc", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{0: False, 1: False}" - ] - }, - "execution_count": 27, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await iis_stack.request_shaking_states()" - ] - }, - { - "cell_type": "markdown", - "id": "7f34eb41-cb4a-4fbb-8c7c-36400dead6c4", - "metadata": {}, - "source": [ - "## Closing Connection\n", - "\n", - "Standard PyLabRobot way of closing the communication to the machine, i.e. the stack:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2c6fd6d7-c85c-4fc3-a35c-e23b9b18ee1d", - "metadata": {}, - "outputs": [], - "source": [ - "await iis_stack.stop()" - ] - }, - { - "cell_type": "markdown", - "id": "20cf4f13-0e54-4c26-9201-83140b738c35", - "metadata": {}, - "source": [ - "This stops all temperature control, and all shaking before disconnecting from the stack." - ] - }, - { - "cell_type": "markdown", - "id": "509f8bc7-9e9c-4250-b854-b2a0c8ded9e8", - "metadata": {}, - "source": [ - "```{note}\n", - "If you develop a small script that you find yourself re-using and that goes beyond the simple \"hello world, inheco incubator shaker\"-style examples here, please consider contributing it back to the PyLabRobot community as a Cookbook Recipe.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "98db023d-d081-4bce-be7e-466735b439c7", - "metadata": {}, - "source": [ - "---\n", - "## Usage: Multiple Stacks\n", - "\n", - "To connect more than one machine stack:\n", - "- instantiate a separate backend and frontend for each,\n", - "- you **must** hand the serial port to each stack's backend explicitly\n", - "\n", - "```{note}\n", - "When using one stack, PyLabRobot finds the machine's port automatically based on its unique VID:PID,\n", - "if multiple machines are found with the same VID:PID there is ambiguity\n", - "- e.g. the VSpin & Cytation 5 use the same identifier combo :')\n", - "```\n", - "- perform a setup for each stack. \n", - "\n", - "(set on the back of the bottom-most machine):\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1f7a4e50-9888-4327-8de1-097fe523590a", - "metadata": {}, - "outputs": [], - "source": [ - "iis_stack_backend_0 = InhecoIncubatorShakerStackBackend(dip_switch_id = 2, port=\"/dev/cu.usbserial-130\")\n", - "iis_stack_0 = IncubatorShakerStack(backend=iis_stack_backend_0)\n", - "await iis_stack.setup(verbose=True)\n", - "\n", - "iis_stack_backend_1 = InhecoIncubatorShakerStackBackend(dip_switch_id = 7, port=\"/dev/cu.usbserial-42\")\n", - "iis_stack_1 = IncubatorShakerStack(backend=iis_stack_backend_1)\n", - "await iis_stack_1.setup(verbose=True)\n", - "\n", - "iis_stack_backend_2 = InhecoIncubatorShakerStackBackend(dip_switch_id = 11, port=\"/dev/cu.usbserial-123\")\n", - "iis_stack_2 = IncubatorShakerStack(backend=iis_stack_backend_2)\n", - "await iis_stack_2.setup(verbose=True)" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/01_material-handling/storage/inheco/scila.ipynb b/docs/user_guide/01_material-handling/storage/inheco/scila.ipynb deleted file mode 100644 index ea3a9768ab7..00000000000 --- a/docs/user_guide/01_material-handling/storage/inheco/scila.ipynb +++ /dev/null @@ -1,480 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "fc3ebf5f", - "metadata": {}, - "source": [ - "# Inheco SCILA\n", - "\n", - "| Summary | Image |\n", - "|------------|--------|\n", - "|
  • Automated CO₂-controlled incubator with 4 independently accessible drawers for SBS-format plates.
  • OEM Link
  • Communication Protocol / Hardware: SiLA 2 (SOAP/HTTP) / Ethernet
  • Communication Level: SiLA 2 interface (documentation shared by OEM)
  • 4 independent drawers for SBS-format plates
  • Temperature control (single zone, all drawers)
  • CO₂ and H₂O valve monitoring
  • Humidification reservoir level monitoring
  • Only one drawer can be open at a time
|
![scila](img/inheco_scila.png)
Figure: Inheco SCILA
|" - ] - }, - { - "cell_type": "markdown", - "id": "201cd7c5", - "metadata": {}, - "source": "## Setup (Physical)\n\nThe SCILA communicates over Ethernet using the SiLA 2 protocol. To connect, you need:\n1. The IP address of the SCILA on your network.\n2. (Optional) The IP address of your client machine — auto-detected if omitted.\n\nThe backend starts a local HTTP server to receive asynchronous responses from the SCILA." - }, - { - "cell_type": "markdown", - "id": "ee0d5aa9-d897-480b-b292-9b375807ec5b", - "metadata": {}, - "source": "## Setup (Programmatic)" - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "be3f3cf1-9529-4fe1-a3dc-6e60adbfe979", - "metadata": {}, - "outputs": [], - "source": [ - "import asyncio" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "474289aa", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.storage.inheco.scila import SCILABackend\n", - "\n", - "scila = SCILABackend(scila_ip=\"169.254.1.117\")\n", - "await scila.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "3cdfd2e4-90e5-46b9-a106-3d7b9f6afefd", - "metadata": {}, - "source": [ - "## Usage" - ] - }, - { - "cell_type": "markdown", - "id": "da156d46", - "metadata": {}, - "source": "### Status Requests" - }, - { - "cell_type": "markdown", - "id": "0lk1xxxljdj", - "metadata": {}, - "source": [ - "Device status (`\"standBy\"`, `\"inError\"`, `\"startup\"`, ...):" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "d5dc6eda", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'idle'" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_status()" - ] - }, - { - "cell_type": "markdown", - "id": "s58zkv4u6in", - "metadata": {}, - "source": [ - "Water level in the built-in humidification reservoir (e.g. `\"High\"`, `\"Low\"`). The SCILA uses this reservoir to maintain humidity inside the drawers:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "faaef501", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Empty'" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_liquid_level()" - ] - }, - { - "cell_type": "markdown", - "id": "lbq75ufdvi", - "metadata": {}, - "source": [ - "Drawer status for all 4 drawers:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "5bc58e44", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Closed'" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_drawer_status(1)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "4251a9e7-4b9f-47ff-bae5-7b51cc9dc68a", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{1: 'Closed', 2: 'Closed', 3: 'Closed', 4: 'Closed'}" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_drawer_statuses()" - ] - }, - { - "cell_type": "markdown", - "id": "d65rfs6q106", - "metadata": {}, - "source": [ - "CO₂ and H₂O valve status:" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "6e7b7e2a", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'H2O': 'Opened', 'CO2 Normal': 'Opened', 'CO2 Boost': 'Closed'}" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_valve_status()" - ] - }, - { - "cell_type": "markdown", - "id": "xukq5oku2wr", - "metadata": {}, - "source": [ - "CO₂ flow status:" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "0b4dd0ce", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'NOK'" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_co2_flow_status()" - ] - }, - { - "cell_type": "markdown", - "id": "cqkptekq5n", - "metadata": {}, - "source": [ - "Status of a single drawer:" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "d30745a8", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Closed'" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_drawer_status(3)" - ] - }, - { - "cell_type": "markdown", - "id": "e5c47abe", - "metadata": {}, - "source": "### Drawer Control\n\nOnly one drawer can be open at a time. Opening a second drawer while one is already open will raise an error." - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "63ea94b0", - "metadata": {}, - "outputs": [], - "source": [ - "await scila.open(2)" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "3d1bca31", - "metadata": {}, - "outputs": [], - "source": [ - "await scila.close(2)" - ] - }, - { - "cell_type": "markdown", - "id": "f6d1452a", - "metadata": {}, - "source": "### Temperature Control\n\nThe SCILA has a single temperature zone shared across all 4 drawers." - }, - { - "cell_type": "markdown", - "id": "bxbcga2l5a", - "metadata": {}, - "source": [ - "Current temperature in °C:" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "f88c9c83", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "23.65" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.measure_temperature()" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "cc2f6063", - "metadata": {}, - "outputs": [], - "source": [ - "await scila.start_temperature_control(37.0)" - ] - }, - { - "cell_type": "markdown", - "id": "dpc7iwuky", - "metadata": {}, - "source": [ - "Check the target temperature and current temperature after starting:" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "5f04d0ef", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "37.0" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.request_target_temperature()" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "02a60f32", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "24.53" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await asyncio.sleep(4)\n", - "\n", - "await scila.measure_temperature()" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "id": "470241e1", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.is_temperature_control_enabled()" - ] - }, - { - "cell_type": "markdown", - "id": "mq0aijzng", - "metadata": {}, - "source": [ - "Stop temperature control and verify it is disabled:" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "id": "5881c2ce", - "metadata": {}, - "outputs": [], - "source": [ - "await scila.stop_temperature_control()" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "id": "ac8ad797", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "False" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scila.is_temperature_control_enabled()" - ] - }, - { - "cell_type": "markdown", - "id": "iq7rt8xr7zg", - "metadata": {}, - "source": "## Closing Connection\n\nClose the SiLA 2 HTTP server and disconnect from the SCILA." - }, - { - "cell_type": "code", - "execution_count": 19, - "id": "oshdz1zxyz", - "metadata": {}, - "outputs": [], - "source": [ - "await scila.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/docs/user_guide/01_material-handling/storage/liconic.ipynb b/docs/user_guide/01_material-handling/storage/liconic.ipynb deleted file mode 100644 index 051b0cb40a5..00000000000 --- a/docs/user_guide/01_material-handling/storage/liconic.ipynb +++ /dev/null @@ -1,260 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "b63b4656", - "metadata": {}, - "source": [ - "# Liconic STX Series\n", - "\n", - "The Liconic STX line of automated incubators come in a variety of sizes including STX 1000, STX 500, STX 280, STX 220, STX 110, STX 44. Which corresponds to the number of plates each size can store using the standard 22 plate capacity cassettes/cartridges (plate height 17mm, 505mm total height.) There are other cassette size for plates height ranging from 5 to 104mm in height (higher plates = less number of plates storage capacity.)\n", - "\n", - "The Liconic STX line comes in a variety of climate control options including Ultra High Temp. (HTT), Incubator (IC), Dry Storage (DC2), Humid Cooler (HC), Humid Wide Range (HR), Dry Wide Range (DR2), Humidity Controlled (AR), Deep Freezer (DF) and Ultra Deep Freezer (UDF). Each have different ranges of temperatures and humidity control ability.\n", - "\n", - "Other accessories that can be included with the STX and can be utilized with this driver include N2 gassing, CO2 gassing, a Turn Station (rotation of plates 90 degrees on the transfer station), internal barcode scanners, a swap station (two transfer plate positions that can be rotated 180 degrees) and internal shaking. \n", - "\n", - "This tutorial shows how to\n", - " - Connect the Liconic incubator\n", - " - Configure racks\n", - " - Move plates in and out\n", - " - Set and monitor temperature and humidity values" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fcd75e15", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.barcode_scanners import BarcodeScanner, KeyenceBarcodeScannerBackend\n", - "from pylabrobot.resources.coordinate import Coordinate\n", - "from pylabrobot.storage import ExperimentalLiconicBackend\n", - "from pylabrobot.storage.incubator import Incubator\n", - "from pylabrobot.storage.liconic.racks import liconic_rack_17mm_22, liconic_rack_44mm_10\n", - "\n", - "\n", - "barcode_scanner_backend = KeyenceBarcodeScannerBackend(port=\"COM4\")\n", - "barcode_scanner = BarcodeScanner(backend=barcode_scanner_backend)\n", - "\n", - "liconic_backend = ExperimentalLiconicBackend(port=\"COM3\", model=\"STX220_HC\", barcode_scanner=barcode_scanner)\n", - "\n", - "rack = [\n", - " liconic_rack_44mm_10(\"cassette_0\"),\n", - " liconic_rack_44mm_10(\"cassette_1\"),\n", - " liconic_rack_44mm_10(\"cassette_2\"),\n", - " liconic_rack_17mm_22(\"cassette_3\"),\n", - " liconic_rack_17mm_22(\"cassette_4\"),\n", - " liconic_rack_17mm_22(\"cassette_5\"),\n", - " liconic_rack_17mm_22(\"cassette_6\"),\n", - " liconic_rack_17mm_22(\"cassette_7\"),\n", - " liconic_rack_17mm_22(\"cassette_8\"),\n", - " liconic_rack_17mm_22(\"cassette_9\")\n", - "]\n", - "\n", - "incubator = Incubator(\n", - " backend=liconic_backend,\n", - " name=\"My Incubator\",\n", - " size_x=100, size_y=100, size_z=100, # stubs for now...\n", - " racks=rack,\n", - " loading_tray_location=Coordinate(x=0, y=0, z=0),\n", - ")\n", - "\n", - "await incubator.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "19b3a6cc", - "metadata": { - "vscode": { - "languageId": "plaintext" - } - }, - "source": [ - "## Setup\n", - "\n", - "To setup the incubator and start sending commands first the backed needs to be declared. For the Liconic the LiconcBackend class is used with the COM port used for connection (in this case COM3) and the model needs to specified (in this case the STX 220 Humid Cooler, STX220_HC). If an internal barcode is installed the barcode_installed parameter is set to True and its COM port is also specified. These two parameters are optional so can be omitted for Liconics without an internal barcode scanner. \n", - "\n", - "Given a STX 220 (220 plate position / 22 plates per rack = 10 racks) can hold 10 racks the list of racks is built and includes mixing and matching different plate height racks. The differences in racks are handled prior to plate retrieval and storage. \n", - "\n", - "Once the these are built the base Incubator class is created and the connection to the incubator is initialized using:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9d7a4f49", - "metadata": {}, - "outputs": [], - "source": [ - "await incubator.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "52f79811", - "metadata": {}, - "source": [ - "## Usage\n", - "\n", - "To store a plate first a plate resource is initialized and then assigned to the loading tray. The method take_in_plate is then called on the incubator object." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d26e039d", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import Azenta4titudeFrameStar_96_wellplate_200ul_Vb\n", - "\n", - "new_plate = Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name=\"TEST\")\n", - "incubator.loading_tray.assign_child_resource(new_plate)\n", - "await incubator.take_in_plate(\"smallest\") # choose the smallest free site\n", - "\n", - "# other options:\n", - "# await incubator.take_in_plate(\"random\") # random free site\n", - "# await incubator.take_in_plate(rack[3]) # store at rack position 3" - ] - }, - { - "cell_type": "markdown", - "id": "85dcddb7", - "metadata": {}, - "source": [ - "To retrieve a plate the plate name can used" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "00308838", - "metadata": {}, - "outputs": [], - "source": [ - "await incubator.fetch_plate_to_loading_tray(plate_name=\"TEST\")\n", - "retrieved = incubator.loading_tray.resource" - ] - }, - { - "cell_type": "markdown", - "id": "0045e703", - "metadata": {}, - "source": [ - "You can also print a barcode from this call (if barcode is installed per the backend insatiation). Returning of the barcode as a return object still needs to be implemented. Currently the barcode is just printed to the terminal.\n", - "\n", - "Barcode can returned by setting the read_barcode to True for \n", - "- take_in_plate\n", - "- fetch_plate_to_loading_tray\n", - "- move_position_to_position" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8730560", - "metadata": {}, - "outputs": [], - "source": [ - "position = rack[9][0] # rack number 9 position 1\n", - "\n", - "await incubator.fetch_plate_to_loading_tray(plate_name=\"TEST\", read_barcode=True)\n", - "\n", - "await incubator.take_in_plate(position, read_barcode=True)\n" - ] - }, - { - "cell_type": "markdown", - "id": "d137d333", - "metadata": {}, - "source": [ - "move plate from one internal position to another internal position" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1aa68983", - "metadata": {}, - "outputs": [], - "source": [ - "await liconic_backend.move_position_to_position(plate=new_plate, dest_site=position, read_barcode=True)\n", - "# will set new_plate.barcode to the barcode read from the plate at position and move it to position." - ] - }, - { - "cell_type": "markdown", - "id": "14efdf69", - "metadata": {}, - "source": [ - "The humdity, temperature, N2 gas and CO2 gas levels can all be controlled and queried. For temperature for example:\n", - "\n", - "- To get the current temperature" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "73e38f2a", - "metadata": {}, - "outputs": [], - "source": [ - "temperature = await liconic_backend.get_temperature() # returns temperature as float in Celsius to the 10th place\n", - "print(str(temperature))" - ] - }, - { - "cell_type": "markdown", - "id": "c7383277", - "metadata": {}, - "source": [ - "- To set the temperature of the Liconic" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2c51c385", - "metadata": {}, - "outputs": [], - "source": [ - "await incubator.set_temperature(8.0) # set the temperature to 8 degrees Celsius" - ] - }, - { - "cell_type": "markdown", - "id": "4f07f349", - "metadata": {}, - "source": [ - "- You can also retrieve the set value (the value sent for set_temperature) using:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "288dea91", - "metadata": {}, - "outputs": [], - "source": [ - "set_temperature = await liconic_backend.get_target_temperature() # will return a float for the set temperature in degrees Celsius" - ] - }, - { - "cell_type": "markdown", - "id": "3a1d9ef3", - "metadata": {}, - "source": [ - "This pattern is the same for CO2, N2 and Humidity control of the Liconic. " - ] - } - ], - "metadata": { - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/01_material-handling/storage/storage.rst b/docs/user_guide/01_material-handling/storage/storage.rst index 698d153b308..7a3eabfdefe 100644 --- a/docs/user_guide/01_material-handling/storage/storage.rst +++ b/docs/user_guide/01_material-handling/storage/storage.rst @@ -140,6 +140,3 @@ Combined Retrieval & Access Summary :hidden: cytomat - inheco/incubator_shaker - inheco/scila - liconic diff --git a/docs/user_guide/01_material-handling/temperature-controllers/inheco.ipynb b/docs/user_guide/01_material-handling/temperature-controllers/inheco.ipynb deleted file mode 100644 index dc6d46422ad..00000000000 --- a/docs/user_guide/01_material-handling/temperature-controllers/inheco.ipynb +++ /dev/null @@ -1,222 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Hello World, Inheco CPAC!\n", - "\n", - "The Inheco CPAC is a `TemperatureController` machine that enables:\n", - "- heating & cooling\n", - "\n", - "...of plates.\n", - "\n", - "- Variants:\n", - " - **CPAC Microplate**:\n", - " - Part number: 7000179\n", - " - Temperature: +4°C to +70°C\n", - " - **CPAC Microplate HT 2TEC**:\n", - " - Part number: 7000163\n", - " - Temperature: +4°C to + 110°C\n", - " - **CPAC Ultraflat**:\n", - " - Part number: 7000190, 7000193\n", - " - Temperature: +4°C to +70°C\n", - " - **CPAC Ultraflat HT 2TEC**:\n", - " - Part number: 7000166, 7000165\n", - " - Temperature: +4°C to + 110°C\n", - "\n", - "Check out the [CPAC User and installation manual](https://www.inheco.com/data/pdf/cpac-manual-1019-0826-30.pdf) for more information." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Setup Instructions (Physical)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "\n", - "Connect the ThermoShake to the Inheco TEC Control Box using the provided cable. The Control Box can control up to 6 ThermoShakes. Plug the Control Box into a power outlet and connect it to your computer using a USB B cable.\n", - "\n", - "There are two versions of the TEC Control Box:\n", - "\n", - "- The Inheco Single TEC Control (STC) unit controls one device.\n", - "- The Inheco Multi TEC Control (MTC) unit can control up to 6 Inheco devices in parallel.\n", - "\n", - "See [https://www.inheco.com/tec-controller-and-slot-modules.html](https://www.inheco.com/tec-controller-and-slot-modules.html) for more information like slot module variants.\n", - "\n", - "Also check out the [CPAC User and installation manual](https://www.inheco.com/data/pdf/cpac-manual-1019-0826-30.pdf)." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Usage" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.temperature_controlling import InhecoTECControlBox\n", - "\n", - "control_box = InhecoTECControlBox()\n", - "await control_box.setup()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.temperature_controlling import inheco_cpac_ultraflat\n", - "\n", - "tc = inheco_cpac_ultraflat(\n", - " name=\"CPAC\",\n", - " control_box=control_box,\n", - " index=1,\n", - ")\n", - "await tc.setup()\n", - "type(tc)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Temperature Control" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await tc.get_temperature() # Get current temperature in C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await tc.set_temperature(37) # Temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await tc.wait_for_temperature() # Wait for the temperature to stabilize" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await tc.deactivate() # Turn off temperature control" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Closing Connection to Machine" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await tc.stop()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Closing Connection to Control Box\n", - "\n", - "When all devices are no longer needed, the connection to the control box can be closed using the {meth}`~pylabrobot.temperature_controller.inheco.control_box.InhecoTECControlBox.stop` method. This will close the connection to the control box." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await control_box.stop()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Using Multiple Inheco Devices" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "You can use multiple Inheco ThermoShake machines using one control box if you are using the Inheco MTC (Multi TEC Control) unit. In this case, simply instantiate more than one {class}`~pylabrobot.temperature_controlling.inheco.cpac_backend.InhecoCPACBackend` with the same {class}`~pylabrobot.temperature_controlling.inheco.control_box.InhecoTECControlBox` instance, but different `index` values (1-6)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "tc2 = inheco_cpac_ultraflat(\n", - " name=\"CPAC\",\n", - " control_box=control_box,\n", - " index=2,\n", - ")\n", - "await tc2.setup()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/01_material-handling/temperature-controllers/temperature-controllers.rst b/docs/user_guide/01_material-handling/temperature-controllers/temperature-controllers.rst index 96ccb19511d..70fe4db05a6 100644 --- a/docs/user_guide/01_material-handling/temperature-controllers/temperature-controllers.rst +++ b/docs/user_guide/01_material-handling/temperature-controllers/temperature-controllers.rst @@ -126,4 +126,3 @@ e.g.: ot-temperature-controller hamilton-heater-cooler - inheco diff --git a/docs/user_guide/01_material-handling/thermocycling/inheco-odtc.ipynb b/docs/user_guide/01_material-handling/thermocycling/inheco-odtc.ipynb deleted file mode 100644 index d0782d21857..00000000000 --- a/docs/user_guide/01_material-handling/thermocycling/inheco-odtc.ipynb +++ /dev/null @@ -1,316 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Inheco ODTC (On Deck Thermal Cycler)\n", - "\n", - "The Inheco ODTC is an on-deck thermal cycler designed for automated PCR workflows. It features:\n", - "\n", - "- Precise temperature control for PCR cycling\n", - "- Heated lid to prevent condensation\n", - "- Motorized door for automated plate handling\n", - "- SiLA 2 communication interface\n", - "\n", - "**Specifications:**\n", - "- Temperature range: 4°C to 99°C\n", - "- Heating/cooling rate: up to 4.4°C/s\n", - "- 96-well plate format\n", - "\n", - "See the [Inheco ODTC product page](https://www.inheco.com/odtc.html) for more information." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Setup\n", - "\n", - "The ODTC communicates over Ethernet using the SiLA 2 protocol. You'll need:\n", - "1. The IP address of the ODTC\n", - "2. Network connectivity between your computer and the ODTC" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources.coordinate import Coordinate\n", - "from pylabrobot.thermocycling.inheco import ExperimentalODTCBackend\n", - "from pylabrobot.thermocycling.thermocycler import Thermocycler\n", - "\n", - "odtc = Thermocycler(\n", - " name=\"odtc\",\n", - " size_x=159.0,\n", - " size_y=245.0,\n", - " size_z=228.0,\n", - " backend=ExperimentalODTCBackend(ip=\"169.254.151.99\"), # Replace with your ODTC's IP address\n", - " child_location=Coordinate(0, 0, 0) # TODO: resource modeling...\n", - ")\n", - "await odtc.setup()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Door Control\n", - "\n", - "Open and close the door for plate access:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.open_lid()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.close_lid()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Temperature Control\n", - "\n", - "### Reading Sensor Data\n", - "\n", - "Get current temperatures from all sensors:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "sensor_data = await odtc.backend.get_sensor_data()\n", - "print(sensor_data)\n", - "# Example output:\n", - "# {'Mount': 25.0, 'Mount_Monitor': 25.1, 'Lid': 30.0, 'Lid_Monitor': 30.1,\n", - "# 'Ambient': 22.0, 'PCB': 28.0, 'Heatsink': 26.0, 'Heatsink_TEC': 25.5}" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Setting Block Temperature\n", - "\n", - "Set a constant block temperature. Note that the ODTC uses a \"pre-method\" approach which takes several minutes to stabilize:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.set_block_temperature([37.0]) # Set to 37°C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Check current block temperature\n", - "temp = await odtc.get_block_current_temperature()\n", - "print(f\"Block temperature: {temp[0]}°C\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Deactivating Temperature Control" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.deactivate_block()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Running PCR Protocols\n", - "\n", - "The ODTC can run complex PCR protocols defined using `Protocol`, `Stage`, and `Step` objects.\n", - "\n", - "### Defining a Protocol\n", - "\n", - "A protocol consists of stages, each containing steps with temperature and hold time. Stages can repeat multiple times for cycling." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.thermocycling.standard import Protocol, Stage, Step\n", - "\n", - "# Example: Standard 3-step PCR protocol\n", - "pcr_protocol = Protocol(\n", - " stages=[\n", - " # Initial denaturation\n", - " Stage(\n", - " steps=[Step(temperature=[95.0], hold_seconds=300)], # 95°C for 5 min\n", - " repeats=1\n", - " ),\n", - " # PCR cycling (30 cycles)\n", - " Stage(\n", - " steps=[\n", - " Step(temperature=[95.0], hold_seconds=30), # Denature: 95°C for 30s\n", - " Step(temperature=[55.0], hold_seconds=30), # Anneal: 55°C for 30s\n", - " Step(temperature=[72.0], hold_seconds=60), # Extend: 72°C for 60s\n", - " ],\n", - " repeats=30\n", - " ),\n", - " # Final extension\n", - " Stage(\n", - " steps=[Step(temperature=[72.0], hold_seconds=600)], # 72°C for 10 min\n", - " repeats=1\n", - " ),\n", - " # Hold\n", - " Stage(\n", - " steps=[Step(temperature=[4.0], hold_seconds=0)], # 4°C hold\n", - " repeats=1\n", - " ),\n", - " ]\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Running the Protocol" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.run_protocol(\n", - " protocol=pcr_protocol,\n", - " block_max_volume=20.0, # Maximum sample volume in µL\n", - " start_block_temperature=25.0, # Starting block temperature\n", - " start_lid_temperature=105.0, # Lid temperature (typically 105°C to prevent condensation)\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Custom Ramp Rates\n", - "\n", - "You can specify custom temperature ramp rates for each step:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Protocol with custom ramp rates\n", - "custom_protocol = Protocol(\n", - " stages=[\n", - " Stage(\n", - " steps=[\n", - " Step(temperature=[95.0], hold_seconds=60, rate=4.4), # Fast ramp (4.4°C/s)\n", - " Step(temperature=[60.0], hold_seconds=30, rate=2.0), # Slower ramp (2.0°C/s)\n", - " ],\n", - " repeats=1\n", - " ),\n", - " ]\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.run_protocol(\n", - " protocol=custom_protocol,\n", - " block_max_volume=25.0,\n", - " start_block_temperature=25.0,\n", - " start_lid_temperature=105.0,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Closing the Connection" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await odtc.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.24" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/01_material-handling/thermocycling/thermocycling.md b/docs/user_guide/01_material-handling/thermocycling/thermocycling.md index 9d76b3231a5..daa7e4bfca8 100644 --- a/docs/user_guide/01_material-handling/thermocycling/thermocycling.md +++ b/docs/user_guide/01_material-handling/thermocycling/thermocycling.md @@ -15,8 +15,3 @@ Thermocyclers are essential for temperature-controlled processes like PCR (Polym - Opentrons Thermocycler -```{toctree} -:maxdepth: 1 - -Inheco ODTC -``` diff --git a/docs/user_guide/02_analytical/scales/mettler-toledo-WXS205SDU.ipynb b/docs/user_guide/02_analytical/scales/mettler-toledo-WXS205SDU.ipynb deleted file mode 100644 index 483ee4079ce..00000000000 --- a/docs/user_guide/02_analytical/scales/mettler-toledo-WXS205SDU.ipynb +++ /dev/null @@ -1,328 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Mettler Toledo Precision Scales\n", - "\n", - "| Summary | Image |\n", - "|------------|--------|\n", - "|
  • OEM Link
  • Communication Protocol / Hardware: Serial / RS-232
  • Communication Level: Firmware (documentation shared by OEM)
  • Compatibility: This backend has been extensively tested on the WXS205SDU (but according to firmware documentation is applicable to other Mettler Toledo \"Automated Precision Weigh Modules\", including the WX and WMS series)
  • VID:PID: 0x0403:0x6001
  • Description: High-precision fine balance with various adapters available.
  • Load range: 0 - 220 g
  • Readability: 0.1 mg
|
![shaker](img/mettler_toledo_wx_scale.png)
Figure: Mettler Toledo WXS205SDU used for gravimetric liquid transfer verification
|" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Setup (Physical)\n", - "\n", - "The WXS205SDU scale system consists of 2 required units and 1 optional unit:\n", - "\n", - "### Machine Components\n", - "\n", - "| | **1. Load Cell** | **2. Electronic Unit** | **3. Terminal/Display** |\n", - "|-----------|-------|-------------|-------------|\n", - "| **Image** |
![load_cell](img/mt_load_cell.png) |
![electronic_unit](img/mt_electronic_unit.png) |
![terminal](img/mt_terminal.png) |\n", - "| **Description** | The weighing platform where samples are placed | The control and communication module | Optional: For manual reading of measurements |\n", - "\n", - "### Mettler Toledo Terminology\n", - "\n", - "| Configuration Name | Has Load Cell | Has Electronics Unit | Has Terminal/Display |\n", - "|---------------|---------------|-----------------|---------------------|\n", - "| **Balance** | ✓ | ✓ | ✓ |\n", - "| **Weigh Module** (or \"Bridge\") | ✓ | ✓ | ✗ |\n", - "\n", - "**Note:** When used with PyLabRobot, the terminal/display is optional since all control is done programmatically.\n", - "\n", - "### Connection\n", - "\n", - "The scale communicates via an RS-232 serial port.\n", - "\n", - "To connect it to your computer, you'll likely need a USB-to-serial adapter.\n", - "Any generic adapter using an FTDI chipset (typically ~$10) should work fine." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Setup (Programmatic)\n", - "\n", - "Import the necessary classes:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.scales import Scale\n", - "from pylabrobot.scales.mettler_toledo_backend import MettlerToledoWXS205SDUBackend\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Initialize the scale backend and create a scale instance.\n", - "You'll need to specify the serial port where your scale is connected:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0.00148" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "backend = MettlerToledoWXS205SDUBackend(port=\"/dev/cu.usbserial-110\")\n", - "scale = Scale(name=\"scale\", backend=backend, size_x=0, size_y=0, size_z=0)\n", - "\n", - "await scale.setup()\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "```{Warning}\n", - "### Warm-up Time Required\n", - "\n", - "This scale requires a **warm-up period** after being powered on. Mettler Toledo documentation specifies 60-90 minutes, though in practice 30 minutes is often sufficient.\n", - "\n", - "If you attempt measurements before the scale has warmed up, you'll likely encounter an error: *\"Command understood but currently not executable (balance is currently executing another command)\"*.\n", - "\n", - "**Tip**: Sometimes power-cycling the scale (unplugging and replugging the power cord) can help resolve initialization issues.\n", - "```\n", - "\n", - "\n", - "```{Note}\n", - "This scale is the same model used in the Hamilton Liquid Verification Kit (LVK).\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Usage\n", - "\n", - "The scale implements the three core methods required for all PyLabRobot scales.\n", - "\n", - "They are presented here in typical workflow order:\n", - "\n", - "### `.zero()`\n", - "\n", - "Calibrates the scale to read zero when the platform is empty.\n", - "Unlike taring, this establishes the baseline \"empty\" reading without accounting for any container weight.\n", - "Use this at the start of a workflow or after removing all items from the platform." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await scale.zero(timeout=5)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "```{note}\n", - "See the [Scales documentation](./scales.rst) for details on the ``timeout`` parameter and when to use different timeout modes.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### `.tare()`\n", - "\n", - "Resets the scale reading to zero while accounting for the weight of a container already on the platform. Use this when you want to measure only the weight of material being added to a container.\n", - "\n", - "**Example workflow**:\n", - "Place an empty beaker on the scale → tare → dispense liquid → read only the liquid's weight." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await scale.tare(timeout=5)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The difference between load at `scale.zero()` and load at `scale.tare()` is stored in and can be retrieved from the scales's memory:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await scale.request_tare_weight()\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "\n", - "### `read_weight()`\n", - "\n", - "Retrieves the current weight measurement from the scale **in grams**." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0.00148" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await scale.read_weight(timeout=0)\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "### Typical Workflow\n", - "\n", - "Here's a common pattern for gravimetric liquid transfer (i.e. aspiration AND dispensation) verification:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import asyncio\n", - "\n", - "# 1. Zero the scale\n", - "await scale.zero(timeout=\"stable\")\n", - "\n", - "# 2. Place container with liquid on scale\n", - "\n", - "# 3. Aspirate liquid from container (on scale)\n", - "# (your liquid handling code here)\n", - "\n", - "# 4. Tare the scale (ignore weight loss from aspiration)\n", - "await scale.tare(timeout=5)\n", - "\n", - "# 5. Dispense liquid back into same container (on scale)\n", - "# (your liquid handling code here)\n", - "\n", - "# 6. Brief pause to allow scale to settle\n", - "await asyncio.sleep(1) # Allow 1 second for settling after dispense\n", - "\n", - "# 7. Read the weight of dispensed liquid\n", - "weight_g = await scale.read_weight(timeout=5)\n", - "\n", - "# 8. Convert weight to volume\n", - "weight_mg = weight_g * 1000\n", - "liquid_density = 1.06 # mg/µL for 50% v/v glycerol at ~25°C, 1 atm\n", - "volume_uL = weight_mg / liquid_density\n", - "\n", - "print(f\"Dispensed {weight_mg:.2f} mg or ({volume_uL:.2f} µL)\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "### Performance Characterization\n", - "\n", - "#### Example: Measuring Read Time\n", - "\n", - "You can easily benchmark the scale's performance using standard Python timing:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "100.44 ms ± 6.78 ms\n" - ] - } - ], - "source": [ - "import time\n", - "import numpy as np\n", - "\n", - "times = []\n", - "for i in range(10):\n", - " t0 = time.monotonic_ns()\n", - " await scale.read_weight(timeout=\"stable\")\n", - " t1 = time.monotonic_ns()\n", - " times.append((t1 - t0) / 1e6)\n", - "\n", - "print(f\"{np.mean(times):.2f} ms ± {np.std(times):.2f} ms\")\n" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.12" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/user_guide/02_analytical/scales/scales.rst b/docs/user_guide/02_analytical/scales/scales.rst index e1a63147be4..871509d9b21 100644 --- a/docs/user_guide/02_analytical/scales/scales.rst +++ b/docs/user_guide/02_analytical/scales/scales.rst @@ -130,5 +130,3 @@ parameter that controls how the scale handles measurement stability. .. toctree:: :maxdepth: 1 :hidden: - - mettler-toledo-WXS205SDU diff --git a/docs/user_guide/agilent/biotek/el406/hello-world.ipynb b/docs/user_guide/agilent/biotek/el406/hello-world.ipynb index 83d51a4e351..2dcd44565e1 100644 --- a/docs/user_guide/agilent/biotek/el406/hello-world.ipynb +++ b/docs/user_guide/agilent/biotek/el406/hello-world.ipynb @@ -76,7 +76,7 @@ "cell_type": "markdown", "id": "sevahtcfwm", "metadata": {}, - "source": "## Manifold (plate washing)\n\nThe wash manifold is the primary fluid system." + "source": "## Manifold (plate washing)\n\nThe wash manifold is the primary fluid system. Use {class}`~pylabrobot.agilent.biotek.el406.plate_washing_backend.EL406PlateWashingBackend.WashParams` to configure buffer, soak, and shake options, and {class}`~pylabrobot.agilent.biotek.el406.plate_washing_backend.EL406PlateWashingBackend.PrimeParams` for priming." }, { "cell_type": "code", @@ -90,11 +90,7 @@ "cell_type": "markdown", "id": "1y8kqv8xazr", "metadata": {}, - "source": [ - "## Syringe pump\n", - "\n", - "Precise low-volume dispensing via dual syringe pumps (A/B)." - ] + "source": "## Syringe pump\n\nPrecise low-volume dispensing via dual syringe pumps (A/B). Use {class}`~pylabrobot.agilent.biotek.el406.syringe_dispensing_backend.EL406SyringeDispensingBackend.PrimeParams` to select which syringe to prime, and {class}`~pylabrobot.agilent.biotek.el406.syringe_dispensing_backend.EL406SyringeDispensingBackend.DispenseParams` for dispense options." }, { "cell_type": "code", @@ -122,11 +118,7 @@ "cell_type": "markdown", "id": "gky56c7k2h", "metadata": {}, - "source": [ - "## Peristaltic pump\n", - "\n", - "Continuous-flow dispensing with cassette selection and row/column masking." - ] + "source": "## Peristaltic pump\n\nContinuous-flow dispensing with cassette selection and row/column masking. Use {class}`~pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend.EL406PeristalticDispensingBackend.PrimeParams` to set the flow rate for priming, and {class}`~pylabrobot.agilent.biotek.el406.peristaltic_dispensing_backend.EL406PeristalticDispensingBackend.DispenseParams` for dispense options." }, { "cell_type": "code", diff --git a/docs/user_guide/azenta/a4s/hello-world.ipynb b/docs/user_guide/azenta/a4s/hello-world.ipynb new file mode 100644 index 00000000000..9cfbb6de402 --- /dev/null +++ b/docs/user_guide/azenta/a4s/hello-world.ipynb @@ -0,0 +1,135 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f3ht9hmqmtb", + "source": "# Azenta a4S\n\n| Summary | Photo |\n|---|---|\n| - [OEM Link](https://www.azenta.com/products/automated-roll-heat-sealer-formerly-a4s)
- **Communication Protocol / Hardware**: Serial / USB-A
- **Communication Level**: Firmware (documentation shared by OEM)
- **Sealing Method**: Thermal (heat + pressure)
- **Compressed Air Required?**: No
- **Typical Seal Time**: ~7 seconds

The a4S has two programmatically-accessible action parameters for sealing: temperature and sealing duration. | ![a4s](img/azenta_a4s.png) |", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "toywe80qo6l", + "source": "## Setup\n\nThe a4S exposes a [Sealer](../../capabilities/sealing) and a [Temperature Controller](../../capabilities/temperature-control).\n\nIdentify the serial port on your control PC and create an `A4S` instance:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "taszk7zqg4f", + "source": "from pylabrobot.azenta import A4S\n\ns = A4S(name=\"a4s\", port=\"/dev/tty.usbserial-0001\")\nawait s.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "jfzh8gnavi", + "source": "```{note}\nWhen the a4S is first powered on, it will open its loading tray -- the **machine default state is open**.\n\nIf this is the first time you are using the a4S, follow the OEM's instructions to load a foil/film roll using the required metal film loading tool.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "fcc7k7g481u", + "source": "## Sealing\n\nThe a4S exposes a {class}`~pylabrobot.capabilities.sealing.sealing.Sealer` on `s.sealer`. For the full API, see [Sealing](../../capabilities/sealing).\n\nSeal a plate with a single command:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "mc8bowt5ph", + "source": "await s.sealer.seal(\n temperature=180, # degrees Celsius\n duration=5, # seconds\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "6zqpnsgzwjl", + "source": "This command will:\n\n1. Set the temperature\n2. Wait until the temperature is reached\n3. Move the plate into the machine / close the loading tray\n4. Cut the film off its roll\n5. Seal the film onto the plate for the specified duration\n6. Move the plate out of the machine / open the loading tray\n\nYou can also open and close the loading tray independently:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "dihxxyypl27", + "source": "await s.sealer.close()\nawait s.sealer.open()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "exik1ymzpec", + "source": "```{warning}\nClosing the loading tray also **cuts the film/foil** without performing a seal. A single leaf of film will fall onto the tray (or onto a plate if one is present). Opening afterwards will require manual removal of that leaf.\n\nThis is a mechanical safety feature of the a4S design -- without it the film could buckle and stick to hot internals.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "kk2eozbe1y", + "source": "## Temperature control\n\nThe a4S exposes a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `s.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control).\n\nPre-set the temperature to accelerate subsequent sealing steps:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "zfgs53tvjnl", + "source": "await s.tc.set_temperature(170)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "dtpbheb2x2b", + "source": "current = await s.tc.request_current_temperature()\nprint(f\"{current:.1f} °C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "31sbr45l31n", + "source": "await s.tc.deactivate()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ttf1c07n5pc", + "source": "## Querying machine status\n\nThe a4S driver exposes detailed status information including sensor states:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "qv9xuidyncq", + "source": "status = await s.driver.request_status()\nprint(\"current_temperature: \", status.current_temperature)\nprint(\"system_status: \", status.system_status)\nprint(\"heater_block_status: \", status.heater_block_status)\nprint(\"error_code: \", status.error_code)\nprint(\"warning_code: \", status.warning_code)\nprint(\"sensor_status:\")\nprint(\" shuttle_middle_sensor: \", status.sensor_status.shuttle_middle_sensor)\nprint(\" shuttle_open_sensor: \", status.sensor_status.shuttle_open_sensor)\nprint(\" shuttle_close_sensor: \", status.sensor_status.shuttle_close_sensor)\nprint(\" clean_door_sensor: \", status.sensor_status.clean_door_sensor)\nprint(\" seal_roll_sensor: \", status.sensor_status.seal_roll_sensor)\nprint(\" heater_motor_up_sensor: \", status.sensor_status.heater_motor_up_sensor)\nprint(\" heater_motor_down_sensor: \", status.sensor_status.heater_motor_down_sensor)\nprint(\"remaining_time: \", status.remaining_time)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "u3d9sho4ajn", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "9y7upc8miwj", + "source": "await s.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/azenta/a4s/img/azenta_a4s.png b/docs/user_guide/azenta/a4s/img/azenta_a4s.png new file mode 100644 index 0000000000000000000000000000000000000000..30bf0e0dba84748898f4b2c1e0bf57a583b3acf2 GIT binary patch literal 183206 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�+a{d3LnmMV%}1 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≥V7tBq^95p}P&hAWCyyK-2sqz2&`+r07 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
  • OEM Link
  • Communication: SiLA 2 (SOAP/HTTP) over Ethernet
  • 4 independent drawers for SBS-format plates
  • Temperature control (single zone, all drawers)
  • CO₂ and H₂O valve monitoring
  • Humidification reservoir level monitoring
  • Only one drawer can be open at a time
  • |
    ![scila](img/inheco_scila.png)
    Inheco SCILA
    |\n\n**Capabilities:**\n- [Temperature control](../../capabilities/temperature-control) (heating, single zone shared across all drawers)", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "qnrt30ol6yc", + "source": "## Setup\n\nThe SCILA communicates over Ethernet using the SiLA 2 protocol. To connect, you need:\n1. The IP address of the SCILA on your network.\n2. (Optional) The IP address of your client machine -- auto-detected if omitted.\n\nThe backend starts a local HTTP server to receive asynchronous responses from the SCILA.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "z5fcugljbwk", + "source": "from pylabrobot.inheco.scila import SCILA\n\nscila = SCILA(name=\"scila\", scila_ip=\"169.254.1.117\") # replace with your IP\nawait scila.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "b5tlrcjqnaw", + "source": "## Status Requests\n\nQuery the overall device status (`\"idle\"`, `\"standBy\"`, `\"inError\"`, `\"startup\"`, ...):", + "metadata": {} + }, + { + "cell_type": "code", + "id": "41szb6vodyd", + "source": "await scila.driver.request_status()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "mubpx1eq1gb", + "source": "Water level in the built-in humidification reservoir (e.g. `\"High\"`, `\"Low\"`, `\"Empty\"`):", + "metadata": {} + }, + { + "cell_type": "code", + "id": "mropytllj29", + "source": "await scila.driver.request_liquid_level()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ssaxq21iau", + "source": "Drawer status for all 4 drawers, or a single drawer:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "1c3ph2ocvsy", + "source": "await scila.driver.request_drawer_statuses()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "ku94ko4ros", + "source": "await scila.driver.request_drawer_status(1)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "p7i6hqwkt1", + "source": "CO₂ and H₂O valve status:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "akbcybg86xv", + "source": "await scila.driver.request_valve_status()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "g6s5bqaxtwc", + "source": "CO₂ flow status:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "sqo8u77mw5c", + "source": "await scila.driver.request_co2_flow_status()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "j4158a7ie6l", + "source": "## Drawer Control\n\nOnly one drawer can be open at a time. Opening a second drawer while one is already open will raise an error.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "7k64plxbq", + "source": "await scila.driver.open(2)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "2z6ayudjc48", + "source": "await scila.driver.close(2)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "eb1yorphqlv", + "source": "## Temperature Control\n\nThe SCILA has a single temperature zone shared across all 4 drawers. Temperature control is exposed via a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `scila.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "o3x6m4pn4k", + "source": "current = await scila.tc.request_temperature()\nprint(f\"{current:.1f} °C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "6wx6jnosfzv", + "source": "await scila.tc.set_temperature(37.0)\nawait scila.tc.wait_for_temperature(tolerance=0.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "4j9wjfu9t2e", + "source": "Stop temperature control:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "wcmkz8nnt7b", + "source": "await scila.tc.deactivate()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ryrjtlm67v", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ebejgue4wyn", + "source": "await scila.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/inheco/scila/img/inheco_scila.png b/docs/user_guide/inheco/scila/img/inheco_scila.png new file mode 100644 index 0000000000000000000000000000000000000000..9a207e0668d61f5f4449cd5578197ecc0ad49e61 GIT binary patch literal 374900 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 z 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� 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 z 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 z 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�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 literal 0 HcmV?d00001 diff --git a/docs/user_guide/inheco/thermoshake/hello-world.ipynb b/docs/user_guide/inheco/thermoshake/hello-world.ipynb new file mode 100644 index 00000000000..4dbedc28654 --- /dev/null +++ b/docs/user_guide/inheco/thermoshake/hello-world.ipynb @@ -0,0 +1,123 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9tc0hvg3nb", + "source": "# Inheco ThermoShake\n\nThe Inheco ThermoShake is a combined heater-cooler-shaker that supports:\n\n- [Shaking](../../capabilities/shaking) (orbital, 60--2000 rpm depending on variant)\n- [Temperature control](../../capabilities/temperature-control) (4 °C to 105 °C, max 25 °C below ambient for cooling)\n\nAll variants share the same serial firmware and are controlled through an Inheco TEC Control Box via HID.\n\n| Model | PLR Name | Cat. No. | Shaking (rpm) | Footprint (mm) | Status |\n|---|---|---|---|---|---|\n| ThermoShake RM | `inheco_thermoshake_rm` | 7100144 | 100--2000 | 147 x 104 x 116 | PLR-tested |\n| ThermoShake | `inheco_thermoshake` | 7100146 | 100--2000 | 147 x 104 x 118 | PLR-untested |\n| ThermoShake AC | `inheco_thermoshake_ac` | 7100160/61 | 300--3000 | 147 x 104 x 115.9 | PLR-untested |\n\nSee the [ThermoShake user manual](https://www.inheco.com/data/pdf/thermoshake-manual-1013-1049-33.pdf) for hardware setup. Connect the ThermoShake to an Inheco TEC Control Box via HID (install `pip install pylabrobot[hid]`).", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "iyjhvd0ewmi", + "source": "## Setup", + "metadata": {} + }, + { + "cell_type": "code", + "id": "jdb9stxlizk", + "source": "from pylabrobot.inheco import InhecoTECControlBox, inheco_thermoshake\n\ncontrol_box = InhecoTECControlBox()\nawait control_box.setup()\n\nts = inheco_thermoshake(name=\"ts\", control_box=control_box, index=1)\nawait ts.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "zftm4fgc8uj", + "source": "The `index` parameter selects which slot on the TEC Control Box the ThermoShake is connected to (1--8). If you have multiple Inheco devices on the same control box, give each a unique index.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "w3zwtxym6aa", + "source": "## Shaking\n\nThe ThermoShake exposes a {class}`~pylabrobot.capabilities.shaking.shaking.Shaker` on `ts.shaker`. For the full API, see [Shaking](../../capabilities/shaking).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "a0du4u05wub", + "source": "# Shake indefinitely at 500 rpm\nawait ts.shaker.shake(speed=500)\n\n# ... do other things ...\n\nawait ts.shaker.stop_shaking()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "0ltnuzf3lsv", + "source": "# Shake for 10 seconds at 300 rpm (blocks until done)\nawait ts.shaker.shake(speed=300, duration=10)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "u1evtvw4gq", + "source": "## Temperature control\n\nThe ThermoShake exposes a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `ts.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "fundvc36m8o", + "source": "await ts.tc.set_temperature(37.0)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "gppm06002e", + "source": "current = await ts.tc.request_temperature()\nprint(f\"{current:.1f} °C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "h1qrrojgrig", + "source": "await ts.tc.deactivate()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "d09ekquz3y7", + "source": "## Multiple devices\n\nWhen using multiple Inheco devices on one control box, create each with a unique `index`:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "3wnpwaknynj", + "source": "import asyncio\n\nts1 = inheco_thermoshake(name=\"ts1\", control_box=control_box, index=1)\nts2 = inheco_thermoshake(name=\"ts2\", control_box=control_box, index=2)\n\nawait asyncio.gather(ts1.setup(), ts2.setup())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "vvjbyllpl7", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "18p0yuw8kd3", + "source": "await ts.stop()\nawait control_box.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/liconic/index.md b/docs/user_guide/liconic/index.md new file mode 100644 index 00000000000..26e50557559 --- /dev/null +++ b/docs/user_guide/liconic/index.md @@ -0,0 +1,7 @@ +# Liconic + +```{toctree} +:maxdepth: 1 + +stx/hello-world +``` diff --git a/docs/user_guide/liconic/stx/hello-world.ipynb b/docs/user_guide/liconic/stx/hello-world.ipynb new file mode 100644 index 00000000000..f5a5e0648cd --- /dev/null +++ b/docs/user_guide/liconic/stx/hello-world.ipynb @@ -0,0 +1,177 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6ru9zu8rvgh", + "source": "# Liconic STX\n\nThe Liconic STX line of automated incubators comes in a variety of sizes (STX 44, STX 110, STX 220, STX 280, STX 500, STX 1000 -- corresponding to the number of plate positions) and climate control options:\n\n| Suffix | Climate type | Temp. control | Active cooling | Humidity control |\n|---|---|---|---|---|\n| IC | Incubator | yes | no | no |\n| HC | Humid Cooler | yes | yes | no |\n| DC2 | Dry Storage | yes | no | yes |\n| HR | Humid Wide Range | yes | yes | no |\n| DR2 | Dry Wide Range | yes | no | yes |\n| AR | Humidity Controlled | yes | no | yes |\n| DF | Deep Freezer | yes | yes | no |\n| NC | No Climate | no | no | no |\n| DH | Dehumidifier | yes | no | yes |\n\nCassettes are available for plate heights from 5 mm to 104 mm and can be mixed within a single unit.\n\nDepending on configuration, the STX supports:\n\n- [Automated retrieval](../../capabilities/automated-retrieval) (store/fetch plates by position or strategy)\n- [Temperature control](../../capabilities/temperature-control) (heating, active cooling on HC/HR/DF models)\n- [Humidity control](../../capabilities/humidity-control) (on DC2/DR2/AR/DH models)\n- [Shaking](../../capabilities/shaking) (optional internal shaker)\n- [Barcode scanning](../../capabilities/barcode-scanning) (optional internal scanner)", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "zyc01aslxmd", + "source": "## Setup\n\nCreate a `Liconic` instance with the model string (e.g. `\"STX220_HC\"`), the serial port, and a list of rack cassettes matching your physical configuration. Connect via RS232.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "78n6b9bza8s", + "source": "from pylabrobot.liconic import Liconic\nfrom pylabrobot.liconic.racks import liconic_rack_17mm_22, liconic_rack_44mm_10\nfrom pylabrobot.resources import Coordinate\n\nracks = [\n liconic_rack_44mm_10(\"cassette_0\"),\n liconic_rack_44mm_10(\"cassette_1\"),\n liconic_rack_44mm_10(\"cassette_2\"),\n liconic_rack_17mm_22(\"cassette_3\"),\n liconic_rack_17mm_22(\"cassette_4\"),\n liconic_rack_17mm_22(\"cassette_5\"),\n liconic_rack_17mm_22(\"cassette_6\"),\n liconic_rack_17mm_22(\"cassette_7\"),\n liconic_rack_17mm_22(\"cassette_8\"),\n liconic_rack_17mm_22(\"cassette_9\"),\n]\n\nincubator = Liconic(\n name=\"incubator\",\n liconic_model=\"STX220_HC\",\n port=\"/dev/ttyUSB0\", # replace with your port\n racks=racks,\n loading_tray_location=Coordinate(x=0, y=0, z=0),\n)\n\nawait incubator.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ox6t79wnst", + "source": "Racks can be mixed -- here we use 44 mm cassettes (10 plates each) for taller plates and 17 mm cassettes (22 plates each) for standard-height plates. See `pylabrobot.liconic.racks` for the full list of available cassette types.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "z1g5xvvw0an", + "source": "## Storing and retrieving plates\n\nPlace a plate on the loading tray, then call `take_in_plate` to store it. You can specify a strategy (`\"smallest\"` picks the smallest free site that fits, `\"random\"` picks a random one) or pass a specific rack site. For the full API, see [Automated Retrieval](../../capabilities/automated-retrieval).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "562vs3qn86", + "source": "from pylabrobot.resources import Azenta4titudeFrameStar_96_wellplate_200ul_Vb\n\nplate = Azenta4titudeFrameStar_96_wellplate_200ul_Vb(name=\"my_plate\")\nincubator.loading_tray.assign_child_resource(plate)\n\nawait incubator.take_in_plate(\"smallest\") # store in the smallest free site that fits", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "lhcpvo2os0s", + "source": "Retrieve a plate by name:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "2w9vsj6i8bp", + "source": "await incubator.fetch_plate_to_loading_tray(plate_name=\"my_plate\")\nretrieved = incubator.loading_tray.resource", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "y8dhvwx6fwm", + "source": "You can also store at a specific rack site or use `\"random\"`:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "rl1sabpoa6i", + "source": "await incubator.take_in_plate(\"random\") # random free site\n# await incubator.take_in_plate(racks[3].sites[0]) # specific rack and position", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ednr8bps09g", + "source": "Print a summary of what is stored where:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "rvqkhqaii3r", + "source": "print(incubator.summary())", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "5w1rdh4gnld", + "source": "## Temperature control\n\nModels with temperature control (all except `_NC`) expose a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `incubator.tc`. For the full API, see [Temperature Control](../../capabilities/temperature-control).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "vubj2yqgir", + "source": "await incubator.tc.set_temperature(37.0)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "jcrwi8xgpgq", + "source": "current = await incubator.tc.request_temperature()\nprint(f\"{current:.1f} °C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "1mgv2un629z", + "source": "## Humidity control\n\nModels with humidity control (`_DC2`, `_DR2`, `_AR`, `_DH`) expose a {class}`~pylabrobot.capabilities.humidity_controlling.humidity_controller.HumidityController` on `incubator.humidity_controller`. For the full API, see [Humidity Control](../../capabilities/humidity-control).\n\nHumidity is expressed as a fraction (0.0--1.0), not a percentage.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "1k625wrxt4t", + "source": "# Only available on models with humidity control (DC2, DR2, AR, DH)\n# await incubator.humidity_controller.set_humidity(0.95) # 95% RH\n# current_rh = await incubator.humidity_controller.request_humidity()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "oo6iuis1qdk", + "source": "## Shaking\n\nIf your STX has an internal shaker installed, pass `has_shaker=True` when constructing the `Liconic`. The shaker is then available at `incubator.shaker`. For the full API, see [Shaking](../../capabilities/shaking).\n\nNote: shaking speed on the Liconic is specified in Hz (1.0--50.0), not RPM.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "7irud68zfns", + "source": "# Only if has_shaker=True was passed during construction:\n# await incubator.shaker.shake(speed=10.0) # 10 Hz\n# await incubator.shaker.stop_shaking()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "b4lkz4atm0g", + "source": "## CO2 and N2 gas control\n\nThe backend exposes direct methods for CO2 and N2 gas level control (when the hardware is equipped):", + "metadata": {} + }, + { + "cell_type": "code", + "id": "50q0mm1ik94", + "source": "# CO2 control (if installed)\n# await incubator.driver.set_co2_level(0.05) # 5% CO2\n# co2 = await incubator.driver.request_co2_level()\n\n# N2 control (if installed)\n# await incubator.driver.set_n2_level(0.10) # 10% N2\n# n2 = await incubator.driver.request_n2_level()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "388eeo49sc2", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "p1jop1zbcpg", + "source": "await incubator.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/mettler_toledo/index.md b/docs/user_guide/mettler_toledo/index.md new file mode 100644 index 00000000000..da3c481fa1e --- /dev/null +++ b/docs/user_guide/mettler_toledo/index.md @@ -0,0 +1,7 @@ +# Mettler Toledo + +```{toctree} +:maxdepth: 1 + +wxs205sdu/hello-world +``` diff --git a/docs/user_guide/mettler_toledo/wxs205sdu/hello-world.ipynb b/docs/user_guide/mettler_toledo/wxs205sdu/hello-world.ipynb new file mode 100644 index 00000000000..7294a987eb9 --- /dev/null +++ b/docs/user_guide/mettler_toledo/wxs205sdu/hello-world.ipynb @@ -0,0 +1,113 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "5pmhklv4dll", + "source": "# Mettler Toledo WXS205SDU\n\nThe WXS205SDU is a high-precision automated weigh module from Mettler Toledo, commonly used for gravimetric liquid transfer verification (e.g. in the Hamilton Liquid Verification Kit).\n\n| Property | Value |\n|---|---|\n| [OEM Link](https://www.mt.com/gb/en/home/products/Industrial_Weighing_Solutions/high-precision-weigh-sensors/weigh-module-wxs205sdu-15-11121008.html) | |\n| Communication | Serial / RS-232 |\n| VID:PID | `0x0403:0x6001` |\n| Load range | 0 -- 220 g |\n| Readability | 0.1 mg |\n\nThe backend has been tested on the WXS205SDU but, per Mettler Toledo firmware documentation, should be applicable to other \"Automated Precision Weigh Modules\" in the WX and WMS series.\n\n**Capabilities:** [Weighing](../../capabilities/weighing)", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "8v1qlztfpn", + "source": "## Physical setup\n\nThe system consists of two required units and one optional unit:\n\n| Component | Required | Description |\n|---|---|---|\n| Load Cell | yes | The weighing platform where samples are placed |\n| Electronic Unit | yes | The control and communication module |\n| Terminal/Display | no | For manual reading; not needed when using PyLabRobot |\n\nConnect the electronic unit to your computer via the RS-232 serial port. You will likely need a USB-to-serial adapter (any generic FTDI-based adapter should work).\n\n```{warning}\nThe scale requires a warm-up period after being powered on. Mettler Toledo specifies 60--90 minutes, though 30 minutes is often sufficient in practice. If you attempt measurements before warm-up, you may see: *\"Command understood but currently not executable\"*.\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "1mzumf8u2bu", + "source": "## Setup", + "metadata": {} + }, + { + "cell_type": "code", + "id": "djeqvngxd3c", + "source": "from pylabrobot.mettler_toledo import MettlerToledoWXS205SDUDriver, MettlerToledoWXS205SDUScaleBackend\nfrom pylabrobot.capabilities.weighing import Scale\n\ndriver = MettlerToledoWXS205SDUDriver(port=\"/dev/cu.usbserial-110\") # replace with your port\nbackend = MettlerToledoWXS205SDUScaleBackend(driver=driver)\nscale = Scale(backend=backend)\n\nawait driver.setup()\nawait scale.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "opa273q0bvc", + "source": "## Weighing\n\nThe scale exposes the standard [Weighing](../../capabilities/weighing) capability: `zero()`, `tare()`, and `read_weight()`.\n\n### Zero\n\nCalibrates the scale to read zero with an empty platform. Use at the start of a workflow.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "dqc8j70q1m", + "source": "await scale.zero()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "jtaypw6mx8", + "source": "### Tare\n\nResets the displayed weight to zero while accounting for a container already on the platform. Place your container, then tare, so subsequent readings reflect only the added material.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "k0b0bx9qaqq", + "source": "await scale.tare()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "i8ewtl6bitj", + "source": "### Read weight\n\nReturns the current weight in grams.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "77so0rlhk39", + "source": "weight = await scale.read_weight()\nprint(f\"Weight: {weight} g\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "p4p4hpeg11", + "source": "### Backend-specific methods\n\nThe backend exposes additional methods beyond the standard capability interface. You can access them through `scale.backend`:\n\n- `request_tare_weight()` -- retrieve the stored tare value\n- `request_serial_number()` -- read the scale's serial number\n- `clear_tare()` -- clear the stored tare weight\n- `zero(timeout=...)` / `tare(timeout=...)` / `read_weight(timeout=...)` -- pass a timeout mode (`\"stable\"`, `0`, or seconds). See the [Weighing capability docs](../../capabilities/weighing) for details on timeout modes.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "7ha7yrkc069", + "source": "tare_value = await scale.backend.request_tare_weight()\nprint(f\"Stored tare: {tare_value} g\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "oq3ck6rntr", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "q7m594v11eb", + "source": "await scale.stop()\nawait driver.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/thermo_fisher/index.md b/docs/user_guide/thermo_fisher/index.md new file mode 100644 index 00000000000..dfe61f75d47 --- /dev/null +++ b/docs/user_guide/thermo_fisher/index.md @@ -0,0 +1,7 @@ +# Thermo Fisher + +```{toctree} +:maxdepth: 1 + +multidrop_combi/hello-world +``` diff --git a/docs/user_guide/thermo_fisher/multidrop_combi/hello-world.ipynb b/docs/user_guide/thermo_fisher/multidrop_combi/hello-world.ipynb new file mode 100644 index 00000000000..0fbe1a386e9 --- /dev/null +++ b/docs/user_guide/thermo_fisher/multidrop_combi/hello-world.ipynb @@ -0,0 +1,302 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a1b2c3d4", + "metadata": {}, + "source": "# Thermo Scientific Multidrop Combi\n\nThe Multidrop Combi is a peristaltic pump reagent dispenser for bulk dispensing into 96-, 384-, and 1536-well plates. It communicates via RS232/USB serial at 9600 baud.\n\nPLR exposes it as a {class}`~pylabrobot.thermo_fisher.multidrop_combi.multidrop_combi.MultidropCombi` device with a {class}`~pylabrobot.capabilities.bulk_dispensers.peristaltic.peristaltic.PeristalticDispensing` capability." + }, + { + "cell_type": "markdown", + "id": "e5f6g7h8", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "i9j0k1l2", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.thermo_fisher.multidrop_combi import MultidropCombi\n", + "\n", + "md = MultidropCombi(port=\"/dev/ttyUSB0\") # replace with your port\n", + "await md.setup()" + ] + }, + { + "cell_type": "markdown", + "id": "m3n4o5p6", + "metadata": {}, + "source": [ + "On connect, the driver enters remote control mode and retrieves instrument info:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "q7r8s9t0", + "metadata": {}, + "outputs": [], + "source": "info = md.driver.get_version()\nprint(f\"{info['instrument_name']} FW {info['firmware_version']} SN {info['serial_number']}\")" + }, + { + "cell_type": "markdown", + "id": "u1v2w3x4", + "metadata": {}, + "source": [ + "## Plate configuration\n", + "\n", + "The Multidrop has 10 factory plate types (indexed 0--9). Use `plate_to_type_index` to map a PLR plate to the best-fit factory type, or `plate_to_pla_params` to define a custom plate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "y5z6a7b8", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb\n", + "from pylabrobot.thermo_fisher.multidrop_combi import plate_to_type_index, plate_to_pla_params\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(\"my_plate\")\n", + "\n", + "# Try factory type first\n", + "try:\n", + " type_idx = plate_to_type_index(plate)\n", + " print(f\"Factory plate type: {type_idx}\")\n", + "except ValueError:\n", + " # No factory match -- define a custom plate\n", + " pla_params = plate_to_pla_params(plate)\n", + " await md.peristaltic.backend.define_plate(**pla_params)\n", + " print(f\"Custom plate defined: {pla_params}\")" + ] + }, + { + "cell_type": "markdown", + "id": "c9d0e1f2", + "metadata": {}, + "source": [ + "## Cassette type\n", + "\n", + "Set the cassette type before dispensing. The Multidrop supports Standard (0), Small (1), and two user-defined types (2--3)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "g3h4i5j6", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.thermo_fisher.multidrop_combi import CassetteType\n", + "\n", + "await md.peristaltic.backend.set_cassette_type(CassetteType.STANDARD)" + ] + }, + { + "cell_type": "markdown", + "id": "k7l8m9n0", + "metadata": {}, + "source": "## Priming\n\nPrime the hoses before dispensing to fill the tubing with reagent. Use {class}`~pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend.MultidropCombiPeristalticDispensingBackend.PrimeParams` for device-specific settings like prime mode." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "o1p2q3r4", + "metadata": {}, + "outputs": [], + "source": [ + "await md.peristaltic.prime(plate=plate, volume=500.0) # 500 uL" + ] + }, + { + "cell_type": "markdown", + "id": "s5t6u7v8", + "metadata": {}, + "source": [ + "## Dispensing\n", + "\n", + "Dispense to the plate. Pass a `volumes` dict mapping 1-indexed column numbers to volumes in uL." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "w9x0y1z2", + "metadata": {}, + "outputs": [], + "source": [ + "# 10 uL to all 12 columns\n", + "await md.peristaltic.dispense(\n", + " plate=plate,\n", + " volumes={col: 10.0 for col in range(1, 13)},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "a3b4c5d6", + "metadata": {}, + "source": "Use {class}`~pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend.MultidropCombiPeristalticDispensingBackend.DispenseParams` for device-specific settings like plate type, dispensing height, pump speed, and dispensing order:" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e7f8g9h0", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.thermo_fisher.multidrop_combi import (\n", + " MultidropCombiPeristalticDispensingBackend,\n", + " DispensingOrder,\n", + ")\n", + "\n", + "await md.peristaltic.dispense(\n", + " plate=plate,\n", + " volumes={col: 25.0 for col in range(1, 13)},\n", + " backend_params=MultidropCombiPeristalticDispensingBackend.DispenseParams(\n", + " plate_type=0,\n", + " dispensing_height=2000,\n", + " pump_speed=50,\n", + " dispensing_order=DispensingOrder.COLUMN_WISE,\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "i1j2k3l4", + "metadata": {}, + "source": [ + "Different volumes per column:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "m5n6o7p8", + "metadata": {}, + "outputs": [], + "source": [ + "await md.peristaltic.dispense(\n", + " plate=plate,\n", + " volumes={1: 10.0, 2: 20.0, 3: 30.0},\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "q9r0s1t2", + "metadata": {}, + "source": [ + "## Shaking\n", + "\n", + "The Multidrop has a built-in plate shaker. Shake is a blocking command that returns when done." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "u3v4w5x6", + "metadata": {}, + "outputs": [], + "source": [ + "await md.peristaltic.backend.shake(time=3.0, distance=2, speed=10) # 3s, 2mm, 10Hz" + ] + }, + { + "cell_type": "markdown", + "id": "y7z8a9b0", + "metadata": {}, + "source": "## Purging\n\nPurge (empty) the hoses after dispensing to clear the tubing. Use {class}`~pylabrobot.thermo_fisher.multidrop_combi.peristaltic_dispensing_backend.MultidropCombiPeristalticDispensingBackend.PurgeParams` for device-specific settings like empty mode." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c1d2e3f4", + "metadata": {}, + "outputs": [], + "source": [ + "await md.peristaltic.purge(plate=plate, volume=500.0)" + ] + }, + { + "cell_type": "markdown", + "id": "g5h6i7j8", + "metadata": {}, + "source": [ + "## Moving the plate out\n", + "\n", + "Eject the plate to the loading position." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "k9l0m1n2", + "metadata": {}, + "outputs": [], + "source": [ + "await md.peristaltic.backend.move_plate_out()" + ] + }, + { + "cell_type": "markdown", + "id": "o3p4q5r6", + "metadata": {}, + "source": "## Queries\n\nQuery instrument parameters and error logs via {class}`~pylabrobot.thermo_fisher.multidrop_combi.driver.MultidropCombiDriver`." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "s7t8u9v0", + "metadata": {}, + "outputs": [], + "source": "params = await md.driver.report_parameters()\nfor line in params[:5]:\n print(line)" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "w1x2y3z4", + "metadata": {}, + "outputs": [], + "source": "errors = await md.driver.read_error_log()\nprint(errors)" + }, + { + "cell_type": "markdown", + "id": "a5b6c7d8", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e9f0g1h2", + "metadata": {}, + "outputs": [], + "source": [ + "await md.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/migration-guide-for-claude.md b/migration-guide-for-claude.md index 8b74102c4e8..56702c50d84 100644 --- a/migration-guide-for-claude.md +++ b/migration-guide-for-claude.md @@ -29,8 +29,9 @@ Create a `hello-world.ipynb` at the new location. The notebook should: - **Give brief demos of each capability** the device supports (shaking, temperature control, etc.) — just enough to show the device-specific API surface (factory functions, backend params, model-specific notes). - **Link to the capability docs for full API details** rather than duplicating them. Use relative links like `[Shaking](../../capabilities/shaking)` and `[Temperature Control](../../capabilities/temperature-control)`. - **Include a model table** if the device has multiple models/variants, with a "PLR Name" column showing the factory function or class name. +- **Add Sphinx cross-references for BackendParams classes** used in the notebook. In markdown cells, use `{class}\`~pylabrobot...\`` syntax so they link to the API docs. Every BackendParams class that appears in a code cell should be mentioned with a cross-reference in a nearby markdown cell. -See `docs/user_guide/qinstruments/bioshake/hello-world.ipynb` as the reference example. +See `docs/user_guide/qinstruments/bioshake/hello-world.ipynb` as the reference example for structure, and `docs/user_guide/agilent/biotek/el406/hello-world.ipynb` for BackendParams cross-referencing. ### 3. Wire up the toctree @@ -44,30 +45,56 @@ Current structure: docs/user_guide/index.md (Manufacturers toctree) ├── agilent/index.md -> lists biotek/index │ └── biotek/index.md -> lists el406/hello-world (no el406/index.md — only one item) -└── qinstruments/index.md -> lists bioshake/hello-world (no bioshake/index.md — only one item) +├── azenta/index.md -> lists a4s/hello-world, xpeel/hello-world +├── inheco/index.md -> lists cpac, incubator_shaker, odtc, scila, thermoshake +├── liconic/index.md -> lists stx/hello-world +├── mettler_toledo/index.md -> lists wxs205sdu/hello-world +└── qinstruments/index.md -> lists bioshake/hello-world ``` **Adding a device to an existing manufacturer:** add the notebook path to the manufacturer's `index.md` toctree. If the manufacturer previously pointed directly to a single notebook, you'll need to create an intermediate `index.md` now that there are multiple items. **Adding a new manufacturer:** create `/index.md` and add it to the Manufacturers toctree in `docs/user_guide/index.md`. -### 4. Remove the old notebook from the legacy location +### 4. Add to the API reference + +Each manufacturer needs an RST file in `docs/api/` (e.g. `pylabrobot.azenta.rst`) that documents the device classes, drivers, and backends via `autosummary`. If the manufacturer already has an RST file, just add the new device's classes. + +**For nested BackendParams classes** (e.g. `XPeelPeelerBackend.PeelParams`), autosummary can't handle them directly. Use `autoclass` directives instead: + +```rst +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + XPeelPeelerBackend + +.. autoclass:: pylabrobot.azenta.xpeel.XPeelPeelerBackend.PeelParams + :members: +``` + +The RST file must be listed in the `Manufacturers` toctree in `docs/api/pylabrobot.rst`. + +See `docs/api/pylabrobot.azenta.rst` as the reference example. + +### 5. Remove the old notebook from the legacy location Delete the old `.ipynb` file from `00_liquid-handling/`, `01_material-handling/`, or `02_analytical/`. -### 5. Remove the entry from the legacy category toctree +### 6. Remove the entry from the legacy category toctree Remove the device's toctree entry from the parent page (e.g. `heating_shaking.md`, `plate-washing.md`). If a sub-section becomes empty after removal, delete the entire sub-section directory too. The goal is to eventually delete `00_liquid-handling/`, `01_material-handling/`, and `02_analytical/` entirely. Do NOT update other text/links in the legacy pages — just remove the toctree entry and the file. -### 6. Do NOT touch `machines.md` +### 7. Do NOT touch `machines.md` `machines.md` is legacy and will be kept as-is. Don't update links there. -### 7. Build and verify +### 8. Build and verify -Run `make clean-docs && make docs-fast` to build pages from scratch (no API docs). Fix any warnings — the build uses `-W` so warnings are errors. +Run `make clean-docs && make docs` for a full build including API docs. Fix any warnings — the build uses `-W` so warnings are errors. (The only acceptable warning is nbformat's `MissingIDFieldWarning` about cell IDs, which is pre-existing.) ## Rules @@ -77,6 +104,8 @@ Run `make clean-docs && make docs-fast` to build pages from scratch (no API docs - When a device has capabilities (shaking, temperature control, etc.), link to the capability docs — don't duplicate the API walkthrough. - Include a "PLR Name" column in model tables showing the factory function or class name users should import. - Skip intermediate `index.md` files when a level has only one child — point directly to the notebook instead. +- Always add the device's classes/backends to the API reference RST files. +- Use `autoclass` (not `autosummary`) for nested `BackendParams` classes — autosummary can't resolve inner classes. ## Completed migrations @@ -84,3 +113,13 @@ Run `make clean-docs && make docs-fast` to build pages from scratch (no API docs |--------|-------------|--------------| | BioTek EL406 | `00_liquid-handling/plate-washing/biotek-el406.ipynb` | `agilent/biotek/el406/hello-world.ipynb` | | QInstruments BioShake | `01_material-handling/heating_shaking/qinstruments.ipynb` | `qinstruments/bioshake/hello-world.ipynb` | +| Mettler Toledo WXS205SDU | `02_analytical/scales/mettler-toledo-WXS205SDU.ipynb` | `mettler_toledo/wxs205sdu/hello-world.ipynb` | +| Azenta a4S | `01_material-handling/sealers/a4s.ipynb` | `azenta/a4s/hello-world.ipynb` | +| Azenta XPeel | _(no old doc)_ | `azenta/xpeel/hello-world.ipynb` | +| Liconic STX | `01_material-handling/storage/liconic.ipynb` | `liconic/stx/hello-world.ipynb` | +| Inheco ThermoShake | `01_material-handling/heating_shaking/inheco.ipynb` | `inheco/thermoshake/hello-world.ipynb` | +| Inheco CPAC | `01_material-handling/temperature-controllers/inheco.ipynb` | `inheco/cpac/hello-world.ipynb` | +| Inheco SCILA | `01_material-handling/storage/inheco/scila.ipynb` | `inheco/scila/hello-world.ipynb` | +| Inheco Incubator Shaker | `01_material-handling/storage/inheco/incubator_shaker.ipynb` | `inheco/incubator_shaker/hello-world.ipynb` | +| Inheco ODTC | `01_material-handling/thermocycling/inheco-odtc.ipynb` | `inheco/odtc/hello-world.ipynb` | +| Thermo Fisher Multidrop Combi | _(new with codebase)_ | `thermo_fisher/multidrop_combi/hello-world.ipynb` | diff --git a/pylabrobot/bulk_dispensers/__init__.py b/pylabrobot/bulk_dispensers/__init__.py deleted file mode 100644 index e224ad00513..00000000000 --- a/pylabrobot/bulk_dispensers/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from pylabrobot.bulk_dispensers.backend import BulkDispenserBackend -from pylabrobot.bulk_dispensers.bulk_dispenser import BulkDispenser -from pylabrobot.bulk_dispensers.chatterbox import BulkDispenserChatterboxBackend diff --git a/pylabrobot/bulk_dispensers/backend.py b/pylabrobot/bulk_dispensers/backend.py deleted file mode 100644 index 34a3ed97468..00000000000 --- a/pylabrobot/bulk_dispensers/backend.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -from abc import ABCMeta, abstractmethod - -from pylabrobot.legacy.machines.backend import MachineBackend - - -class BulkDispenserBackend(MachineBackend, metaclass=ABCMeta): - """Abstract class for bulk dispenser backends. - - Volumes are specified in microliters (float). Concrete backends are responsible - for converting to instrument-specific units. - """ - - @abstractmethod - async def dispense(self) -> None: - pass - - @abstractmethod - async def prime(self, volume: float) -> None: - pass - - @abstractmethod - async def empty(self, volume: float) -> None: - pass - - @abstractmethod - async def shake(self, time: float, distance: int, speed: int) -> None: - """Shake the plate. - - Args: - time: Shake duration in seconds. - distance: Shake distance in mm (1-5). - speed: Shake frequency in Hz (1-20). - """ - - @abstractmethod - async def move_plate_out(self) -> None: - pass - - @abstractmethod - async def set_plate_type(self, plate_type: int) -> None: - pass - - @abstractmethod - async def set_cassette_type(self, cassette_type: int) -> None: - pass - - @abstractmethod - async def set_column_volume(self, column: int, volume: float) -> None: - """Set dispense volume for a column. - - Args: - column: Column number (0 = all columns). - volume: Volume in microliters. - """ - - @abstractmethod - async def set_dispensing_height(self, height: int) -> None: - """Set dispensing height. - - Args: - height: Height in 1/100 mm (500-5500). - """ - - @abstractmethod - async def set_pump_speed(self, speed: int) -> None: - """Set pump speed as percentage of cassette range. - - Args: - speed: Speed percentage (1-100). - """ - - @abstractmethod - async def set_dispensing_order(self, order: int) -> None: - """Set dispensing order. - - Args: - order: 0 = row-wise, 1 = column-wise. - """ - - @abstractmethod - async def abort(self) -> None: - pass diff --git a/pylabrobot/bulk_dispensers/bulk_dispenser.py b/pylabrobot/bulk_dispensers/bulk_dispenser.py deleted file mode 100644 index db834747e4e..00000000000 --- a/pylabrobot/bulk_dispensers/bulk_dispenser.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -from pylabrobot.bulk_dispensers.backend import BulkDispenserBackend -from pylabrobot.legacy.machines.machine import Machine, need_setup_finished - - -class BulkDispenser(Machine): - """Frontend for bulk reagent dispensers.""" - - def __init__(self, backend: BulkDispenserBackend) -> None: - super().__init__(backend=backend) - self.backend: BulkDispenserBackend = backend - - @need_setup_finished - async def dispense(self, **backend_kwargs) -> None: - await self.backend.dispense(**backend_kwargs) - - @need_setup_finished - async def prime(self, volume: float, **backend_kwargs) -> None: - await self.backend.prime(volume=volume, **backend_kwargs) - - @need_setup_finished - async def empty(self, volume: float, **backend_kwargs) -> None: - await self.backend.empty(volume=volume, **backend_kwargs) - - @need_setup_finished - async def shake(self, time: float, distance: int, speed: int, **backend_kwargs) -> None: - await self.backend.shake(time=time, distance=distance, speed=speed, **backend_kwargs) - - @need_setup_finished - async def move_plate_out(self, **backend_kwargs) -> None: - await self.backend.move_plate_out(**backend_kwargs) - - @need_setup_finished - async def set_plate_type(self, plate_type: int, **backend_kwargs) -> None: - await self.backend.set_plate_type(plate_type=plate_type, **backend_kwargs) - - @need_setup_finished - async def set_cassette_type(self, cassette_type: int, **backend_kwargs) -> None: - await self.backend.set_cassette_type(cassette_type=cassette_type, **backend_kwargs) - - @need_setup_finished - async def set_column_volume(self, column: int, volume: float, **backend_kwargs) -> None: - await self.backend.set_column_volume(column=column, volume=volume, **backend_kwargs) - - @need_setup_finished - async def set_dispensing_height(self, height: int, **backend_kwargs) -> None: - await self.backend.set_dispensing_height(height=height, **backend_kwargs) - - @need_setup_finished - async def set_pump_speed(self, speed: int, **backend_kwargs) -> None: - await self.backend.set_pump_speed(speed=speed, **backend_kwargs) - - @need_setup_finished - async def set_dispensing_order(self, order: int, **backend_kwargs) -> None: - await self.backend.set_dispensing_order(order=order, **backend_kwargs) - - @need_setup_finished - async def abort(self, **backend_kwargs) -> None: - await self.backend.abort(**backend_kwargs) diff --git a/pylabrobot/bulk_dispensers/chatterbox.py b/pylabrobot/bulk_dispensers/chatterbox.py deleted file mode 100644 index eddf02b68c8..00000000000 --- a/pylabrobot/bulk_dispensers/chatterbox.py +++ /dev/null @@ -1,47 +0,0 @@ -from pylabrobot.bulk_dispensers.backend import BulkDispenserBackend - - -class BulkDispenserChatterboxBackend(BulkDispenserBackend): - """A backend that prints operations for testing without hardware.""" - - async def setup(self) -> None: - print("Setting up bulk dispenser.") - - async def stop(self) -> None: - print("Stopping bulk dispenser.") - - async def dispense(self) -> None: - print("Dispensing.") - - async def prime(self, volume: float) -> None: - print(f"Priming with {volume} uL.") - - async def empty(self, volume: float) -> None: - print(f"Emptying with {volume} uL.") - - async def shake(self, time: float, distance: int, speed: int) -> None: - print(f"Shaking for {time}s, distance={distance}mm, speed={speed}Hz.") - - async def move_plate_out(self) -> None: - print("Moving plate out.") - - async def set_plate_type(self, plate_type: int) -> None: - print(f"Setting plate type to {plate_type}.") - - async def set_cassette_type(self, cassette_type: int) -> None: - print(f"Setting cassette type to {cassette_type}.") - - async def set_column_volume(self, column: int, volume: float) -> None: - print(f"Setting column {column} volume to {volume} uL.") - - async def set_dispensing_height(self, height: int) -> None: - print(f"Setting dispensing height to {height}.") - - async def set_pump_speed(self, speed: int) -> None: - print(f"Setting pump speed to {speed}%.") - - async def set_dispensing_order(self, order: int) -> None: - print(f"Setting dispensing order to {order}.") - - async def abort(self) -> None: - print("Aborting.") diff --git a/pylabrobot/bulk_dispensers/tests/__init__.py b/pylabrobot/bulk_dispensers/tests/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/pylabrobot/bulk_dispensers/tests/bulk_dispenser_tests.py b/pylabrobot/bulk_dispensers/tests/bulk_dispenser_tests.py deleted file mode 100644 index 26314145247..00000000000 --- a/pylabrobot/bulk_dispensers/tests/bulk_dispenser_tests.py +++ /dev/null @@ -1,91 +0,0 @@ -import unittest - -from pylabrobot.bulk_dispensers import ( - BulkDispenser, - BulkDispenserBackend, - BulkDispenserChatterboxBackend, -) - - -class BulkDispenserSetupTests(unittest.IsolatedAsyncioTestCase): - """Test setup/stop lifecycle and need_setup_finished guard.""" - - def setUp(self): - self.backend = BulkDispenserChatterboxBackend() - self.dispenser = BulkDispenser(backend=self.backend) - - async def test_methods_fail_before_setup(self): - with self.assertRaises(RuntimeError): - await self.dispenser.dispense() - with self.assertRaises(RuntimeError): - await self.dispenser.prime(volume=100.0) - with self.assertRaises(RuntimeError): - await self.dispenser.abort() - - async def test_setup_and_stop(self): - await self.dispenser.setup() - self.assertTrue(self.dispenser.setup_finished) - await self.dispenser.stop() - self.assertFalse(self.dispenser.setup_finished) - - async def test_context_manager(self): - async with BulkDispenser(backend=BulkDispenserChatterboxBackend()) as d: - self.assertTrue(d.setup_finished) - self.assertFalse(d.setup_finished) - - -class BulkDispenserDelegationTests(unittest.IsolatedAsyncioTestCase): - """Test that frontend methods delegate to the backend.""" - - async def asyncSetUp(self): - self.backend = unittest.mock.MagicMock(spec=BulkDispenserBackend) - self.dispenser = BulkDispenser(backend=self.backend) - self.dispenser._setup_finished = True - - async def test_dispense(self): - await self.dispenser.dispense() - self.backend.dispense.assert_awaited_once() - - async def test_prime(self): - await self.dispenser.prime(volume=50.0) - self.backend.prime.assert_awaited_once_with(volume=50.0) - - async def test_empty(self): - await self.dispenser.empty(volume=100.0) - self.backend.empty.assert_awaited_once_with(volume=100.0) - - async def test_shake(self): - await self.dispenser.shake(time=5.0, distance=3, speed=10) - self.backend.shake.assert_awaited_once_with(time=5.0, distance=3, speed=10) - - async def test_move_plate_out(self): - await self.dispenser.move_plate_out() - self.backend.move_plate_out.assert_awaited_once() - - async def test_set_plate_type(self): - await self.dispenser.set_plate_type(plate_type=3) - self.backend.set_plate_type.assert_awaited_once_with(plate_type=3) - - async def test_set_cassette_type(self): - await self.dispenser.set_cassette_type(cassette_type=1) - self.backend.set_cassette_type.assert_awaited_once_with(cassette_type=1) - - async def test_set_column_volume(self): - await self.dispenser.set_column_volume(column=0, volume=25.0) - self.backend.set_column_volume.assert_awaited_once_with(column=0, volume=25.0) - - async def test_set_dispensing_height(self): - await self.dispenser.set_dispensing_height(height=2500) - self.backend.set_dispensing_height.assert_awaited_once_with(height=2500) - - async def test_set_pump_speed(self): - await self.dispenser.set_pump_speed(speed=50) - self.backend.set_pump_speed.assert_awaited_once_with(speed=50) - - async def test_set_dispensing_order(self): - await self.dispenser.set_dispensing_order(order=1) - self.backend.set_dispensing_order.assert_awaited_once_with(order=1) - - async def test_abort(self): - await self.dispenser.abort() - self.backend.abort.assert_awaited_once() diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/__init__.py b/pylabrobot/bulk_dispensers/thermo_scientific/__init__.py deleted file mode 100644 index 96f12e46471..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi import ( - CassetteType, - DispensingOrder, - EmptyMode, - MultidropCombiBackend, - MultidropCombiCommunicationError, - MultidropCombiError, - MultidropCombiInstrumentError, - PrimeMode, - plate_to_pla_params, - plate_to_type_index, - plate_well_count, -) diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/__init__.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/__init__.py deleted file mode 100644 index 0d4ac7fe73b..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.backend import ( - MultidropCombiBackend, -) -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.enums import ( - CassetteType, - DispensingOrder, - EmptyMode, - PrimeMode, -) -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.errors import ( - MultidropCombiCommunicationError, - MultidropCombiError, - MultidropCombiInstrumentError, -) -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.helpers import ( - plate_to_pla_params, - plate_to_type_index, - plate_well_count, -) diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/actions.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/actions.py deleted file mode 100644 index d0bc885d3bb..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/actions.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Control operations mixin for the Multidrop Combi.""" - -from __future__ import annotations - -from typing import Optional - -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.errors import ( - MultidropCombiCommunicationError, -) -from pylabrobot.io.serial import Serial - - -class MultidropCombiActionsMixin: - """Mixin providing control operations for the Multidrop Combi.""" - - io: Optional[Serial] - - async def abort(self) -> None: - """Send ESC character to abort the current operation.""" - if self.io is None: - raise MultidropCombiCommunicationError("Not connected to instrument", operation="abort") - await self.io.write(b"\x1b") - - async def restart(self) -> None: - """Restart the instrument (equivalent to power cycle).""" - await self._send_command("RST", timeout=10.0) # type: ignore[attr-defined] - - async def acknowledge_error(self) -> None: - """Clear instrument error state.""" - await self._send_command("EAK", timeout=5.0) # type: ignore[attr-defined] diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/backend.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/backend.py deleted file mode 100644 index fe373c6f96d..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/backend.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Composed backend for the Thermo Scientific Multidrop Combi.""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Optional - -from pylabrobot.bulk_dispensers.backend import BulkDispenserBackend -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.actions import ( - MultidropCombiActionsMixin, -) -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.commands import ( - MultidropCombiCommandsMixin, -) -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.communication import ( - MultidropCombiCommunicationMixin, -) -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.queries import ( - MultidropCombiQueriesMixin, -) -from pylabrobot.io.serial import Serial - -logger = logging.getLogger(__name__) - - -class MultidropCombiBackend( - MultidropCombiCommunicationMixin, - MultidropCombiQueriesMixin, - MultidropCombiActionsMixin, - MultidropCombiCommandsMixin, - BulkDispenserBackend, -): - """Backend for the Thermo Scientific Multidrop Combi reagent dispenser. - - Communication is via RS232/USB serial at 9600 baud, 8N1. - - Args: - port: Serial port (e.g. "COM3", "/dev/ttyUSB0"). If None, auto-detected by VID/PID. - timeout: Default serial read timeout in seconds. - """ - - def __init__( - self, - port: str | None = None, - timeout: float = 30.0, - ) -> None: - super().__init__() - self._port = port - self.timeout = timeout - self.io: Optional[Serial] = None - self._command_lock: Optional[asyncio.Lock] = None - self._instrument_name: str = "" - self._firmware_version: str = "" - self._serial_number: str = "" - - async def setup(self) -> None: - self._command_lock = asyncio.Lock() - - # When port is specified, skip VID/PID discovery (the Multidrop is often - # connected via an RS232-to-USB adapter with a different VID/PID). - if self._port: - self.io = Serial( - human_readable_device_name="Multidrop Combi", - port=self._port, - baudrate=9600, - bytesize=8, - parity="N", - stopbits=1, - timeout=self.timeout, - write_timeout=5, - ) - else: - self.io = Serial( - human_readable_device_name="Multidrop Combi", - vid=0x0AB6, - pid=0x0344, - baudrate=9600, - bytesize=8, - parity="N", - stopbits=1, - timeout=self.timeout, - write_timeout=5, - ) - await self.io.setup() - - # Enable XON/XOFF flow control on the underlying serial port - if self.io._ser is not None: - self.io._ser.xonxoff = True - - await self._drain_stale_data() - - info = await self._enter_remote_mode() - self._instrument_name = info["instrument_name"] - self._firmware_version = info["firmware_version"] - self._serial_number = info["serial_number"] - - logger.info( - "Connected to %s (FW: %s, SN: %s)", - self._instrument_name, - self._firmware_version, - self._serial_number, - ) - - # Clear any pending errors - try: - await self.acknowledge_error() - except Exception: - pass - - async def stop(self) -> None: - await self._exit_remote_mode() - if self.io is not None: - await self.io.stop() - self.io = None - self._command_lock = None - - def serialize(self) -> dict: - return { - **super().serialize(), - "port": self._port, - "timeout": self.timeout, - } diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/commands.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/commands.py deleted file mode 100644 index 7e4fd3de14a..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/commands.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Operational commands mixin for the Multidrop Combi. - -All volume parameters at the public interface are in microliters (float). -Internally, volumes are converted to the instrument's native 1/10 uL units. -""" - -from __future__ import annotations - -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.enums import ( - EmptyMode, - PrimeMode, -) - -# Per-command timeout constants (seconds) -COMMAND_TIMEOUTS = { - "SPL": 5.0, - "SCT": 5.0, - "SCV": 5.0, - "SDH": 5.0, - "SPS": 5.0, - "SDO": 5.0, - "SOF": 5.0, - "SPV": 5.0, - "PLA": 5.0, - "EAK": 5.0, - "POU": 10.0, - "RST": 10.0, - "DIS": 120.0, - "PRI": 60.0, - "EMP": 60.0, - "SHA": 120.0, - "BGN": 120.0, -} - - -def _ul_to_tenths(volume_ul: float) -> int: - """Convert microliters to 1/10 uL integer.""" - return round(volume_ul * 10) - - -class MultidropCombiCommandsMixin: - """Mixin providing operational commands for the Multidrop Combi.""" - - async def dispense(self) -> None: - """Dispense liquid to the plate (DIS command).""" - await self._send_command("DIS", timeout=COMMAND_TIMEOUTS["DIS"]) # type: ignore[attr-defined] - - async def prime(self, volume: float, mode: PrimeMode = PrimeMode.STANDARD) -> None: - """Prime dispenser hoses. - - Args: - volume: Prime volume in microliters. - mode: Prime mode (standard, continuous, stop continuous, calibration). - """ - vol_tenths = _ul_to_tenths(volume) - if vol_tenths < 10 or vol_tenths > 100000: - raise ValueError(f"Prime volume must be 1-10000 uL, got {volume} uL") - cmd = f"PRI {vol_tenths}" - if mode != PrimeMode.STANDARD: - cmd += f" {mode.value}" - timeout = COMMAND_TIMEOUTS["PRI"] + volume / 100.0 - await self._send_command(cmd, timeout=timeout) # type: ignore[attr-defined] - - async def empty(self, volume: float, mode: EmptyMode = EmptyMode.STANDARD) -> None: - """Empty dispenser hoses. - - Args: - volume: Empty volume in microliters. - mode: Empty mode (standard or continuous). - """ - vol_tenths = _ul_to_tenths(volume) - if vol_tenths < 10 or vol_tenths > 100000: - raise ValueError(f"Empty volume must be 1-10000 uL, got {volume} uL") - cmd = f"EMP {vol_tenths}" - if mode != EmptyMode.STANDARD: - cmd += f" {mode.value}" - timeout = COMMAND_TIMEOUTS["EMP"] + volume / 100.0 - await self._send_command(cmd, timeout=timeout) # type: ignore[attr-defined] - - async def shake(self, time: float, distance: int, speed: int) -> None: - """Shake the plate. - - Args: - time: Duration in seconds. - distance: Shake distance in mm (1-5). - speed: Shake frequency in Hz (1-20). - """ - if not 1 <= distance <= 5: - raise ValueError(f"Shake distance must be 1-5 mm, got {distance}") - if not 1 <= speed <= 20: - raise ValueError(f"Shake speed must be 1-20 Hz, got {speed}") - time_hundredths = round(time * 100) - if time_hundredths < 1: - raise ValueError(f"Shake time must be > 0, got {time}s") - timeout = COMMAND_TIMEOUTS["SHA"] + time - await self._send_command( # type: ignore[attr-defined] - f"SHA {time_hundredths} {distance} {speed}", timeout=timeout - ) - - async def move_plate_out(self) -> None: - """Move plate carrier to loading position (POU command).""" - await self._send_command( # type: ignore[attr-defined] - "POU", timeout=COMMAND_TIMEOUTS["POU"] - ) - - async def set_plate_type(self, plate_type: int) -> None: - """Set plate type. - - Args: - plate_type: Plate type index (0-29; 0-9 factory-defined, 10-29 user-defined). - """ - if not 0 <= plate_type <= 29: - raise ValueError(f"Plate type must be 0-29, got {plate_type}") - await self._send_command( # type: ignore[attr-defined] - f"SPL {plate_type}", timeout=COMMAND_TIMEOUTS["SPL"] - ) - - async def set_cassette_type(self, cassette_type: int) -> None: - """Set cassette type. - - Args: - cassette_type: Cassette type (0=Standard, 1=Small, 2-3=User-defined). - """ - if not 0 <= cassette_type <= 3: - raise ValueError(f"Cassette type must be 0-3, got {cassette_type}") - await self._send_command( # type: ignore[attr-defined] - f"SCT {cassette_type}", timeout=COMMAND_TIMEOUTS["SCT"] - ) - - async def set_column_volume(self, column: int, volume: float) -> None: - """Set dispense volume for a column. - - Args: - column: Column number (0 = all columns, 1-48 = specific column). - volume: Volume in microliters. - """ - if not 0 <= column <= 48: - raise ValueError(f"Column must be 0-48, got {column}") - vol_tenths = _ul_to_tenths(volume) - await self._send_command( # type: ignore[attr-defined] - f"SCV {column} {vol_tenths}", timeout=COMMAND_TIMEOUTS["SCV"] - ) - - async def set_dispensing_height(self, height: int) -> None: - """Set dispensing height. - - Args: - height: Height in 1/100 mm (500-5500). - """ - if not 500 <= height <= 5500: - raise ValueError(f"Dispensing height must be 500-5500, got {height}") - await self._send_command( # type: ignore[attr-defined] - f"SDH {height}", timeout=COMMAND_TIMEOUTS["SDH"] - ) - - async def set_pump_speed(self, speed: int) -> None: - """Set pump speed as percentage of cassette range. - - Args: - speed: Speed percentage (1-100). - """ - if not 1 <= speed <= 100: - raise ValueError(f"Pump speed must be 1-100, got {speed}") - await self._send_command( # type: ignore[attr-defined] - f"SPS {speed}", timeout=COMMAND_TIMEOUTS["SPS"] - ) - - async def set_dispensing_order(self, order: int) -> None: - """Set dispensing order. - - Args: - order: 0 = row-wise, 1 = column-wise. - """ - if order not in (0, 1): - raise ValueError(f"Dispensing order must be 0 or 1, got {order}") - await self._send_command( # type: ignore[attr-defined] - f"SDO {order}", timeout=COMMAND_TIMEOUTS["SDO"] - ) - - async def set_dispense_offset(self, x_offset: int, y_offset: int) -> None: - """Set X/Y dispense offset. - - Args: - x_offset: X offset in 1/100 mm (±300). - y_offset: Y offset in 1/100 mm (±300). - """ - if not -300 <= x_offset <= 300: - raise ValueError(f"X offset must be ±300, got {x_offset}") - if not -300 <= y_offset <= 300: - raise ValueError(f"Y offset must be ±300, got {y_offset}") - await self._send_command( # type: ignore[attr-defined] - f"SOF {x_offset} {y_offset}", timeout=COMMAND_TIMEOUTS["SOF"] - ) - - async def set_predispense_volume(self, volume: float) -> None: - """Set predispense volume. - - Args: - volume: Predispense volume in microliters. - """ - vol_tenths = _ul_to_tenths(volume) - if vol_tenths < 10 or vol_tenths > 100000: - raise ValueError(f"Predispense volume must be 1-10000 uL, got {volume} uL") - await self._send_command( # type: ignore[attr-defined] - f"SPV {vol_tenths}", timeout=COMMAND_TIMEOUTS["SPV"] - ) - - async def define_plate( - self, - column_positions: int, - row_positions: int, - rows: int, - columns: int, - height: int, - max_volume: int, - x_offset: int = 0, - y_offset: int = 0, - ) -> None: - """Define a remote plate (PLA command). - - Args: - column_positions: Number of column positions. - row_positions: Number of row positions. - rows: Number of rows. - columns: Number of columns. - height: Plate height in 1/100 mm. - max_volume: Maximum well volume in 1/10 uL. - x_offset: X offset in 1/100 mm. - y_offset: Y offset in 1/100 mm. - """ - await self._send_command( # type: ignore[attr-defined] - f"PLA {column_positions} {row_positions} {rows} {columns} " - f"{height} {max_volume} {x_offset} {y_offset}", - timeout=COMMAND_TIMEOUTS["PLA"], - ) - - async def start_protocol( - self, plate_type: int | None = None, protocol_name: str | None = None - ) -> None: - """Start a protocol from instrument memory (BGN command). - - Args: - plate_type: Optional plate type override. - protocol_name: Optional protocol name. - """ - cmd = "BGN" - if plate_type is not None: - cmd += f" {plate_type}" - if protocol_name is not None: - cmd += f" {protocol_name}" - await self._send_command(cmd, timeout=COMMAND_TIMEOUTS["BGN"]) # type: ignore[attr-defined] diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/communication.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/communication.py deleted file mode 100644 index c08de8f02ef..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/communication.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Low-level serial communication mixin for the Multidrop Combi. - -Ported from the SiLA implementation's serial_transport.py to use -pylabrobot's async Serial wrapper. -""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Optional - -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.errors import ( - MultidropCombiCommunicationError, - MultidropCombiInstrumentError, -) -from pylabrobot.io.serial import Serial - -logger = logging.getLogger(__name__) - -STATUS_OK = 0 - -ERROR_DESCRIPTIONS = { - 1: "Internal firmware error", - 2: "Unrecognized command", - 3: "Invalid command arguments", - 4: "Pump position error", - 5: "Plate X position error", - 6: "Plate Y position error", - 7: "Z position error", - 9: "Attempt to reset serial number", - 10: "Nonvolatile parameters lost", - 11: "No more memory for user data", - 12: "Pump or X motor was running", - 13: "X and Z positions conflict", - 14: "Cannot dispense: pump not primed", - 15: "Missing prime vessel", - 16: "Rotor shield not in place", - 17: "Dispense volume for all columns is 0", - 18: "Invalid plate type (bad plate index)", - 19: "Plate has not been defined", - 20: "Invalid rows in plate definition", - 21: "Invalid columns in plate definition", - 22: "Plate height is invalid", - 23: "Plate well volume invalid (too small or too big)", - 24: "Invalid cassette type (bad cassette index)", - 25: "Cassette not defined", - 26: "Invalid volume increment for cassette", - 27: "Invalid maximum volume for cassette", - 28: "Invalid minimum volume for cassette", - 29: "Invalid min/max pump speed for cassette", - 30: "Invalid pump rotor offset in cassette definition", - 32: "Dispensing volume not within cassette limits", - 33: "Invalid selector channel", - 34: "Invalid dispensing speed", - 35: "Dispensing height too low for plate", - 36: "Predispense volume not within cassette limits", - 37: "Invalid dispensing order", - 38: "Invalid X or Y dispensing offset", - 39: "RFID option not present", - 40: "RFID tag not present", - 41: "RFID tag data checksum incorrect", - 43: "Wrong cassette type", - 44: "Protocol/plate in use, cannot modify or delete", - 45: "Protocol/plate/cassette is read-only", -} - - -class MultidropCombiCommunicationMixin: - """Mixin providing low-level serial communication for the Multidrop Combi.""" - - io: Optional[Serial] - _command_lock: Optional[asyncio.Lock] - - async def _send_command(self, cmd: str, timeout: float | None = None) -> list[str]: - """Send a command and return the data lines from the response. - - Args: - cmd: Command string (e.g. "DIS", "SPL 1", "SCV 0 500"). - timeout: Per-command read timeout in seconds. If None, uses default. - - Returns: - List of data lines (between echo and END terminator). - - Raises: - MultidropCombiCommunicationError: If not connected or communication fails. - MultidropCombiInstrumentError: If instrument returns non-zero status code. - """ - if self.io is None or self._command_lock is None: - raise MultidropCombiCommunicationError("Not connected to instrument", operation=cmd) - - assert self.io._ser is not None, "Serial port not open. Did you call setup()?" - - cmd_code = cmd.split()[0] - - async with self._command_lock: - original_timeout = self.io._ser.timeout - if timeout is not None: - self.io._ser.timeout = timeout - try: - logger.debug("TX: %r", cmd) - await self.io.write(f"{cmd}\r".encode("ascii")) - - lines: list[str] = [] - while True: - raw = await self.io.readline() - if not raw: - raise MultidropCombiCommunicationError( - f"Timeout reading response for {cmd_code}", operation=cmd - ) - line = raw.decode("ascii", errors="replace").strip() - logger.debug("RX: %r", line) - if not line: - continue - lines.append(line) - - if line.startswith(cmd_code) and " END " in line: - break - - # Parse status from END terminator - end_line = lines[-1] - parts = end_line.split() - status_code = int(parts[-1]) if parts[-1].isdigit() else -1 - - if status_code != STATUS_OK: - desc = ERROR_DESCRIPTIONS.get(status_code, "Unknown error") - logger.error("Command %s failed (status %d). RX lines: %s", cmd_code, status_code, lines) - raise MultidropCombiInstrumentError(status_code, desc) - - # Return data lines: skip echo (first) and END line (last) - # The instrument may echo just the command code or the full command - data_lines = [] - for line in lines[:-1]: - line_upper = line.strip().upper() - if line_upper == cmd.strip().upper() or line_upper == cmd_code.upper(): - continue - data_lines.append(line) - - return data_lines - - except (MultidropCombiCommunicationError, MultidropCombiInstrumentError): - raise - except Exception as e: - raise MultidropCombiCommunicationError( - f"Communication error during {cmd_code}: {e}", - operation=cmd, - original_error=e, - ) from e - finally: - if timeout is not None: - self.io._ser.timeout = original_timeout - - async def _drain_stale_data(self) -> None: - """Drain any stale data from the serial buffer.""" - if self.io is None: - return - - assert self.io._ser is not None - - await self.io.reset_input_buffer() - await self.io.reset_output_buffer() - - original_timeout = self.io._ser.timeout - self.io._ser.timeout = 0.3 - drained = 0 - try: - while True: - stale = await self.io.readline() - if not stale: - break - drained += 1 - logger.debug("Drained stale data: %r", stale) - finally: - self.io._ser.timeout = original_timeout - if drained: - logger.info("Drained %d stale lines from serial buffer", drained) - - async def _enter_remote_mode(self) -> dict: - """Send VER to enter remote control mode and get instrument info. - - Returns: - Dict with keys: instrument_name, firmware_version, serial_number. - """ - try: - lines = await self._send_command("VER", timeout=5.0) - except Exception as first_err: - logger.warning("VER failed (%s), sending EAK and retrying...", first_err) - try: - await self._send_command("EAK", timeout=5.0) - except Exception: - pass - try: - lines = await self._send_command("VER", timeout=5.0) - except Exception as e: - raise MultidropCombiCommunicationError( - f"VER command failed: {e}", operation="VER", original_error=e - ) from e - - info = { - "instrument_name": "Unknown", - "firmware_version": "Unknown", - "serial_number": "Unknown", - } - if lines: - raw = lines[0] - if raw.upper().startswith("VER "): - raw = raw[4:] - parts = raw.split() - if len(parts) > 0: - info["instrument_name"] = parts[0] - if len(parts) > 1: - info["firmware_version"] = parts[1] - if len(parts) > 2: - info["serial_number"] = parts[2] - - return info - - async def _exit_remote_mode(self) -> None: - """Send QIT to exit remote control mode.""" - try: - await self._send_command("QIT", timeout=5.0) - except Exception: - pass diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/demo_multidrop.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/demo_multidrop.py deleted file mode 100644 index 3b7b59d62c6..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/demo_multidrop.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Demo script for the Multidrop Combi bulk dispenser. - -Usage: - python demo_multidrop.py COM3 # specify port explicitly (recommended) - python demo_multidrop.py # auto-detect by USB VID/PID (native USB only) -""" - -import asyncio -import sys - -from pylabrobot.bulk_dispensers import BulkDispenser -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi import ( - CassetteType, - DispensingOrder, - MultidropCombiBackend, - MultidropCombiInstrumentError, - plate_to_pla_params, - plate_to_type_index, -) -from pylabrobot.resources.eppendorf.plates import Eppendorf_96_wellplate_250ul_Vb - - -def list_serial_ports(): - """List available serial ports to help the user find the right one.""" - try: - import serial.tools.list_ports - - ports = list(serial.tools.list_ports.comports()) - if not ports: - print(" No serial ports found.") - else: - print(" Available ports:") - for p in ports: - print(f" {p.device} - {p.description} (hwid: {p.hwid})") - except ImportError: - print(" (pyserial not installed, cannot list ports)") - - -async def run_step(name: str, coro): - """Run an async operation with error handling. Returns True on success.""" - try: - await coro - print(f" {name}: OK") - return True - except MultidropCombiInstrumentError as e: - print(f" {name}: INSTRUMENT ERROR (status {e.status_code}): {e.description}") - return False - except Exception as e: - print(f" {name}: ERROR: {type(e).__name__}: {e}") - return False - - -async def main(): - port = sys.argv[1] if len(sys.argv) > 1 else None - - if port is None: - print("No COM port specified. Attempting VID/PID auto-discovery...") - print("(This only works with native USB, not RS232-to-USB adapters)\n") - - # --- Create and connect --- - backend = MultidropCombiBackend(port=port, timeout=30.0) - dispenser = BulkDispenser(backend=backend) - - try: - await dispenser.setup() - except Exception as e: - print(f"Connection failed: {e}\n") - list_serial_ports() - print(f"\nUsage: python {sys.argv[0]} ") - return - - try: - # Connection info - info = backend.get_version() - print( - f"Connected: {info['instrument_name']} " - f"FW {info['firmware_version']} SN {info['serial_number']}" - ) - - # --- Query instrument parameters --- - print("\n--- Instrument Parameters ---") - try: - params = await backend.report_parameters() - for line in params[:10]: - print(f" {line}") - if len(params) > 10: - print(f" ... ({len(params)} lines total)") - except Exception as e: - print(f" REP query failed: {type(e).__name__}: {e}") - - # --- Configure using Eppendorf twin.tec 96-well plate --- - print("\n--- Plate Configuration ---") - plate = Eppendorf_96_wellplate_250ul_Vb("demo_plate") - print(f" Plate: {plate.model}") - print(f" Wells: {plate.num_items} ({plate.num_items_y}x{plate.num_items_x})") - print(f" Height: {plate.get_size_z()} mm") - - # Map to factory type, fall back to PLA remote definition - try: - type_idx = plate_to_type_index(plate) - print(f" Matched factory plate type: {type_idx}") - await run_step("Set plate type (SPL)", dispenser.set_plate_type(plate_type=type_idx)) - except ValueError: - pla_params = plate_to_pla_params(plate) - print(f" No factory match, using remote plate definition: {pla_params}") - await run_step("Define plate (PLA)", backend.define_plate(**pla_params)) - - await run_step( - "Set cassette type (SCT)", dispenser.set_cassette_type(cassette_type=CassetteType.STANDARD) - ) - await run_step( - "Set column volume 10 uL (SCV)", dispenser.set_column_volume(column=0, volume=10.0) - ) - - # Dispensing height must be above the plate. Add 3mm clearance. - dispense_height = round(plate.get_size_z() * 100) + 300 - dispense_height = max(500, min(5500, dispense_height)) # clamp to valid range - print(f" Dispensing height: {dispense_height} (plate {plate.get_size_z()}mm + 3mm clearance)") - await run_step( - f"Set dispensing height {dispense_height} (SDH)", - dispenser.set_dispensing_height(height=dispense_height), - ) - await run_step("Set pump speed 50% (SPS)", dispenser.set_pump_speed(speed=50)) - await run_step( - "Set dispensing order row-wise (SDO)", - dispenser.set_dispensing_order(order=DispensingOrder.ROW_WISE), - ) - - # --- Prime --- - print("\n--- Prime ---") - input(" Press Enter to prime (500 uL)...") - await run_step("Prime 500 uL (PRI)", dispenser.prime(volume=500.0)) - - # --- Dispense --- - print("\n--- Dispense ---") - input(" Press Enter to dispense...") - await run_step("Dispense (DIS)", dispenser.dispense()) - - # --- Shake --- - print("\n--- Shake ---") - input(" Press Enter to shake (3s, 2mm, 10Hz)...") - await run_step("Shake (SHA)", dispenser.shake(time=3.0, distance=2, speed=10)) - - # --- Move plate out --- - print("\n--- Move Plate Out ---") - input(" Press Enter to move plate out...") - await run_step("Move plate out (POU)", dispenser.move_plate_out()) - - # --- Empty --- - print("\n--- Empty ---") - input(" Press Enter to empty hoses (500 uL)...") - await run_step("Empty 500 uL (EMP)", dispenser.empty(volume=500.0)) - - print("\n--- Done! Disconnecting. ---") - - finally: - try: - await dispenser.stop() - except Exception as e: - print(f" Disconnect error: {e}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/enums.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/enums.py deleted file mode 100644 index af964de8123..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/enums.py +++ /dev/null @@ -1,25 +0,0 @@ -import enum - - -class CassetteType(enum.IntEnum): - STANDARD = 0 - SMALL = 1 - USER_DEFINED_1 = 2 - USER_DEFINED_2 = 3 - - -class DispensingOrder(enum.IntEnum): - ROW_WISE = 0 - COLUMN_WISE = 1 - - -class PrimeMode(enum.IntEnum): - STANDARD = 0 - CONTINUOUS = 1 - STOP_CONTINUOUS = 2 - CALIBRATION = 3 - - -class EmptyMode(enum.IntEnum): - STANDARD = 0 - CONTINUOUS = 1 diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/errors.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/errors.py deleted file mode 100644 index 0284adaf58a..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/errors.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - - -class MultidropCombiError(Exception): - """Base exception for Multidrop Combi errors.""" - - -class MultidropCombiCommunicationError(MultidropCombiError): - """Serial communication failure (port not found, timeout, connection lost).""" - - def __init__( - self, message: str, operation: str = "", original_error: Exception | None = None - ) -> None: - self.operation = operation - self.original_error = original_error - super().__init__(message) - - -class MultidropCombiInstrumentError(MultidropCombiError): - """Instrument returned a non-zero status code.""" - - def __init__(self, status_code: int, description: str) -> None: - self.status_code = status_code - self.description = description - super().__init__(f"Instrument error (status {status_code}): {description}") diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/helpers.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/helpers.py deleted file mode 100644 index f310107bbd0..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/helpers.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Plate type helpers for the Multidrop Combi. - -Maps PyLabRobot Plate resources to Multidrop Combi plate type indices and -PLA (remote plate definition) command parameters. -""" - -from __future__ import annotations - -from pylabrobot.resources import Plate - -# Multidrop Combi factory plate type definitions (from manual Table 3-3). -# Type index → (well_count, max_plate_height_mm) -# Heights are upper bounds for selecting the best-fit factory type. -_FACTORY_96_WELL_TYPES = [ - # (type_index, max_height_mm) - (0, 18.0), # Type 0: 96-well, 15mm - (1, 30.0), # Type 1: 96-well, 22mm - (2, 55.0), # Type 2: 96-well, 44mm -] - -_FACTORY_384_WELL_TYPES = [ - (3, 8.5), # Type 3: 384-well, 7.5mm - (4, 12.0), # Type 4: 384-well, 10mm - (5, 18.0), # Type 5: 384-well, 15mm - (6, 30.0), # Type 6: 384-well, 22mm - (7, 55.0), # Type 7: 384-well, 44mm -] - -_FACTORY_1536_WELL_TYPES = [ - (8, 7.0), # Type 8: 1536-well, 5mm - (9, 55.0), # Type 9: 1536-well, 10.5mm -] - -# Hardware limits -MAX_COLUMNS = 48 -MAX_ROWS = 32 -MIN_HEIGHT_HUNDREDTHS_MM = 500 # 5mm -MAX_HEIGHT_HUNDREDTHS_MM = 5500 # 55mm -MAX_VOLUME_TENTHS_UL = 25000 # 2500 uL - - -def plate_to_type_index(plate: Plate) -> int: - """Map a PLR Plate to the best-fit Multidrop Combi factory plate type index. - - Selects the factory type based on well count and plate height (size_z). - The smallest factory type whose height threshold accommodates the plate is chosen. - - Args: - plate: A PyLabRobot Plate resource. - - Returns: - Factory plate type index (0-9). - - Raises: - ValueError: If the plate well count is not 96, 384, or 1536, or if the - plate height exceeds all factory type thresholds. - """ - wells = plate.num_items - height_mm = plate.get_size_z() - - if wells == 96: - type_list = _FACTORY_96_WELL_TYPES - elif wells == 384: - type_list = _FACTORY_384_WELL_TYPES - elif wells == 1536: - type_list = _FACTORY_1536_WELL_TYPES - else: - raise ValueError( - f"Unsupported well count: {wells}. " - "Multidrop factory types support 96, 384, or 1536 wells. " - "Use plate_to_pla_params() for custom plate definitions." - ) - - for type_index, max_height in type_list: - if height_mm <= max_height: - return type_index - - raise ValueError( - f"Plate height {height_mm}mm exceeds all factory type thresholds for {wells}-well plates." - ) - - -def plate_to_pla_params(plate: Plate) -> dict: - """Convert a PLR Plate to Multidrop Combi PLA command parameters. - - Use this for plates that don't match factory types (types 0-9), or when you - want precise control over the plate definition sent to the instrument. - The returned dict can be passed directly to ``backend.define_plate(**params)``. - - Args: - plate: A PyLabRobot Plate resource. - - Returns: - Dict with keys matching ``define_plate()`` parameters: - column_positions, row_positions, rows, columns, height, max_volume. - - Raises: - ValueError: If any parameter exceeds Multidrop hardware limits. - """ - columns = plate.num_items_x - rows = plate.num_items_y - height_hundredths = round(plate.get_size_z() * 100) - - # Get max_volume from first well - first_well = plate.get_well("A1") - well_max_volume_tenths = round(first_well.max_volume * 10) - - # Validate against hardware limits - if columns > MAX_COLUMNS: - raise ValueError(f"Plate has {columns} columns, but Multidrop supports at most {MAX_COLUMNS}.") - if rows > MAX_ROWS: - raise ValueError(f"Plate has {rows} rows, but Multidrop supports at most {MAX_ROWS}.") - if height_hundredths < MIN_HEIGHT_HUNDREDTHS_MM: - raise ValueError( - f"Plate height {plate.get_size_z()}mm is below minimum {MIN_HEIGHT_HUNDREDTHS_MM / 100}mm." - ) - if height_hundredths > MAX_HEIGHT_HUNDREDTHS_MM: - raise ValueError( - f"Plate height {plate.get_size_z()}mm exceeds maximum {MAX_HEIGHT_HUNDREDTHS_MM / 100}mm." - ) - if well_max_volume_tenths > MAX_VOLUME_TENTHS_UL: - raise ValueError( - f"Well max volume {first_well.max_volume} uL exceeds Multidrop limit of " - f"{MAX_VOLUME_TENTHS_UL / 10} uL." - ) - - return { - "column_positions": columns, - "row_positions": rows, - "rows": rows, - "columns": columns, - "height": height_hundredths, - "max_volume": well_max_volume_tenths, - } - - -def plate_well_count(plate: Plate) -> int: - """Return the total well count for a plate.""" - return plate.num_items diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/queries.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/queries.py deleted file mode 100644 index c40ce5d8e6b..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/queries.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Query operations mixin for the Multidrop Combi.""" - -from __future__ import annotations - - -class MultidropCombiQueriesMixin: - """Mixin providing query operations for the Multidrop Combi.""" - - _instrument_name: str - _firmware_version: str - _serial_number: str - - def get_version(self) -> dict: - """Return cached instrument identification info. - - Returns: - Dict with keys: instrument_name, firmware_version, serial_number. - """ - return { - "instrument_name": self._instrument_name, - "firmware_version": self._firmware_version, - "serial_number": self._serial_number, - } - - async def report_parameters(self) -> list[str]: - """Report instrument parameters (REP command). - - Returns: - List of parameter lines from the instrument. - """ - result: list[str] = await self._send_command("REP", timeout=10.0) # type: ignore[attr-defined] - return result - - async def read_error_log(self) -> list[str]: - """Read the instrument error log (LOG command). - - Returns: - List of error log lines. - """ - result: list[str] = await self._send_command("LOG", timeout=10.0) # type: ignore[attr-defined] - return result - - async def read_cassette_info(self) -> list[str]: - """Read RFID cassette info (RIR command). - - Returns: - List of cassette info lines. - """ - result: list[str] = await self._send_command("RIR", timeout=5.0) # type: ignore[attr-defined] - return result diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/__init__.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/backend_tests.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/backend_tests.py deleted file mode 100644 index b9063ae1293..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/backend_tests.py +++ /dev/null @@ -1,63 +0,0 @@ -import unittest -from unittest.mock import AsyncMock, MagicMock, patch - -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.backend import ( - MultidropCombiBackend, -) - - -class BackendSerializationTests(unittest.TestCase): - def test_serialize(self): - backend = MultidropCombiBackend(port="COM3", timeout=15.0) - data = backend.serialize() - self.assertEqual(data["type"], "MultidropCombiBackend") - self.assertEqual(data["port"], "COM3") - self.assertEqual(data["timeout"], 15.0) - - def test_serialize_defaults(self): - backend = MultidropCombiBackend() - data = backend.serialize() - self.assertIsNone(data["port"]) - self.assertEqual(data["timeout"], 30.0) - - -class BackendLifecycleTests(unittest.IsolatedAsyncioTestCase): - @patch("pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.backend.Serial") - async def test_setup_and_stop(self, MockSerial): - mock_serial = MagicMock() - mock_serial.setup = AsyncMock() - mock_serial.stop = AsyncMock() - mock_serial.write = AsyncMock() - mock_serial.readline = AsyncMock() - mock_serial.reset_input_buffer = AsyncMock() - mock_serial.reset_output_buffer = AsyncMock() - mock_serial._ser = MagicMock() - mock_serial._ser.timeout = 30.0 - MockSerial.return_value = mock_serial - - # Setup readline responses: drain (empty), VER, EAK - mock_serial.readline.side_effect = [ - b"", # drain - empty - b"VER\r\n", # VER echo - b"MultidropCombi 2.00.29 836-4191\r\n", # VER data - b"VER END 0\r\n", # VER end - b"EAK\r\n", # EAK echo - b"EAK END 0\r\n", # EAK end - ] - - backend = MultidropCombiBackend(port="COM3") - await backend.setup() - - self.assertEqual(backend._instrument_name, "MultidropCombi") - self.assertEqual(backend._firmware_version, "2.00.29") - self.assertEqual(backend._serial_number, "836-4191") - self.assertIsNotNone(backend.io) - - # Reset readline for QIT during stop - mock_serial.readline.side_effect = [ - b"QIT\r\n", - b"QIT END 0\r\n", - ] - await backend.stop() - self.assertIsNone(backend.io) - mock_serial.stop.assert_awaited_once() diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/commands_tests.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/commands_tests.py deleted file mode 100644 index f029ba5e2a1..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/commands_tests.py +++ /dev/null @@ -1,168 +0,0 @@ -import unittest -from unittest.mock import AsyncMock - -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.commands import ( - MultidropCombiCommandsMixin, - _ul_to_tenths, -) -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.enums import PrimeMode - - -class MockCommandsBackend(MultidropCombiCommandsMixin): - """Testable class with _send_command mocked.""" - - def __init__(self): - self._send_command = AsyncMock(return_value=[]) - - -class VolumeConversionTests(unittest.TestCase): - def test_ul_to_tenths(self): - self.assertEqual(_ul_to_tenths(1.0), 10) - self.assertEqual(_ul_to_tenths(50.0), 500) - self.assertEqual(_ul_to_tenths(0.1), 1) - self.assertEqual(_ul_to_tenths(10000.0), 100000) - - def test_ul_to_tenths_rounding(self): - self.assertEqual(_ul_to_tenths(1.06), 11) - self.assertEqual(_ul_to_tenths(1.04), 10) - - -class CommandFormattingTests(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self.backend = MockCommandsBackend() - - async def test_dispense(self): - await self.backend.dispense() - self.backend._send_command.assert_awaited_once() - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "DIS") - - async def test_prime_standard(self): - await self.backend.prime(volume=50.0) - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "PRI 500") - - async def test_prime_continuous(self): - await self.backend.prime(volume=50.0, mode=PrimeMode.CONTINUOUS) - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "PRI 500 1") - - async def test_empty(self): - await self.backend.empty(volume=100.0) - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "EMP 1000") - - async def test_shake(self): - await self.backend.shake(time=5.0, distance=3, speed=10) - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "SHA 500 3 10") - - async def test_move_plate_out(self): - await self.backend.move_plate_out() - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "POU") - - async def test_set_plate_type(self): - await self.backend.set_plate_type(plate_type=3) - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "SPL 3") - - async def test_set_cassette_type(self): - await self.backend.set_cassette_type(cassette_type=1) - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "SCT 1") - - async def test_set_column_volume(self): - await self.backend.set_column_volume(column=0, volume=25.0) - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "SCV 0 250") - - async def test_set_dispensing_height(self): - await self.backend.set_dispensing_height(height=2500) - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "SDH 2500") - - async def test_set_pump_speed(self): - await self.backend.set_pump_speed(speed=50) - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "SPS 50") - - async def test_set_dispensing_order(self): - await self.backend.set_dispensing_order(order=1) - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "SDO 1") - - async def test_set_dispense_offset(self): - await self.backend.set_dispense_offset(x_offset=100, y_offset=-50) - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "SOF 100 -50") - - async def test_set_predispense_volume(self): - await self.backend.set_predispense_volume(volume=10.0) - args = self.backend._send_command.call_args - self.assertEqual(args[0][0], "SPV 100") - - -class ParameterValidationTests(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self.backend = MockCommandsBackend() - - async def test_prime_volume_too_low(self): - with self.assertRaises(ValueError): - await self.backend.prime(volume=0.0) - - async def test_prime_volume_too_high(self): - with self.assertRaises(ValueError): - await self.backend.prime(volume=20000.0) - - async def test_empty_volume_too_low(self): - with self.assertRaises(ValueError): - await self.backend.empty(volume=0.0) - - async def test_shake_distance_out_of_range(self): - with self.assertRaises(ValueError): - await self.backend.shake(time=5.0, distance=0, speed=10) - with self.assertRaises(ValueError): - await self.backend.shake(time=5.0, distance=6, speed=10) - - async def test_shake_speed_out_of_range(self): - with self.assertRaises(ValueError): - await self.backend.shake(time=5.0, distance=3, speed=0) - with self.assertRaises(ValueError): - await self.backend.shake(time=5.0, distance=3, speed=21) - - async def test_plate_type_out_of_range(self): - with self.assertRaises(ValueError): - await self.backend.set_plate_type(plate_type=-1) - with self.assertRaises(ValueError): - await self.backend.set_plate_type(plate_type=30) - - async def test_cassette_type_out_of_range(self): - with self.assertRaises(ValueError): - await self.backend.set_cassette_type(cassette_type=4) - - async def test_column_out_of_range(self): - with self.assertRaises(ValueError): - await self.backend.set_column_volume(column=49, volume=10.0) - - async def test_dispensing_height_out_of_range(self): - with self.assertRaises(ValueError): - await self.backend.set_dispensing_height(height=499) - with self.assertRaises(ValueError): - await self.backend.set_dispensing_height(height=5501) - - async def test_pump_speed_out_of_range(self): - with self.assertRaises(ValueError): - await self.backend.set_pump_speed(speed=0) - with self.assertRaises(ValueError): - await self.backend.set_pump_speed(speed=101) - - async def test_dispensing_order_invalid(self): - with self.assertRaises(ValueError): - await self.backend.set_dispensing_order(order=2) - - async def test_dispense_offset_out_of_range(self): - with self.assertRaises(ValueError): - await self.backend.set_dispense_offset(x_offset=301, y_offset=0) - with self.assertRaises(ValueError): - await self.backend.set_dispense_offset(x_offset=0, y_offset=-301) diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/communication_tests.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/communication_tests.py deleted file mode 100644 index 19e83cdae2f..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/communication_tests.py +++ /dev/null @@ -1,162 +0,0 @@ -import asyncio -import unittest -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.communication import ( - MultidropCombiCommunicationMixin, -) -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.errors import ( - MultidropCombiCommunicationError, - MultidropCombiInstrumentError, -) - - -class MockCommunicationBackend(MultidropCombiCommunicationMixin): - """Testable class that uses the communication mixin with a mock Serial.""" - - def __init__(self) -> None: - self.io: Any = MagicMock() - self.io._ser = MagicMock() - self.io._ser.timeout = 30.0 - self._command_lock = asyncio.Lock() - - # Make io.write and io.readline async - self.io.write = AsyncMock() - self.io.readline = AsyncMock() - self.io.reset_input_buffer = AsyncMock() - self.io.reset_output_buffer = AsyncMock() - - -class SendCommandTests(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - self.backend = MockCommunicationBackend() - - async def test_simple_command(self) -> None: - """Test a simple command with echo + END response.""" - self.backend.io.readline.side_effect = [ - b"SPL\r\n", # echo - b"SPL END 0\r\n", # end with status 0 - ] - result = await self.backend._send_command("SPL 1") - self.assertEqual(result, []) - self.backend.io.write.assert_awaited_once_with(b"SPL 1\r") - - async def test_command_with_data_lines(self) -> None: - """Test a command that returns data lines between echo and END.""" - self.backend.io.readline.side_effect = [ - b"VER\r\n", - b"MultidropCombi 2.00.29 836-4191\r\n", - b"VER END 0\r\n", - ] - result = await self.backend._send_command("VER") - self.assertEqual(result, ["MultidropCombi 2.00.29 836-4191"]) - - async def test_command_with_error_status(self) -> None: - """Test that non-zero status raises MultidropCombiInstrumentError.""" - self.backend.io.readline.side_effect = [ - b"SPL\r\n", - b"SPL END 18\r\n", # status 18 = Invalid plate type - ] - with self.assertRaises(MultidropCombiInstrumentError) as ctx: - await self.backend._send_command("SPL 99") - self.assertEqual(ctx.exception.status_code, 18) - self.assertIn("Invalid plate type", ctx.exception.description) - - async def test_timeout_raises_communication_error(self) -> None: - """Test that timeout (empty readline) raises MultidropCombiCommunicationError.""" - self.backend.io.readline.side_effect = [b""] - with self.assertRaises(MultidropCombiCommunicationError): - await self.backend._send_command("SPL 1") - - async def test_not_connected(self) -> None: - """Test that sending a command when io is None raises error.""" - self.backend.io = None - with self.assertRaises(MultidropCombiCommunicationError): - await self.backend._send_command("VER") - - async def test_custom_timeout(self) -> None: - """Test that custom timeout is set and restored.""" - self.backend.io.readline.side_effect = [ - b"POU\r\n", - b"POU END 0\r\n", - ] - original = self.backend.io._ser.timeout - await self.backend._send_command("POU", timeout=10.0) - # Timeout should be restored after command - self.assertEqual(self.backend.io._ser.timeout, original) - - async def test_echo_skipping_case_insensitive(self) -> None: - """Test that echo is skipped regardless of case.""" - self.backend.io.readline.side_effect = [ - b"ver\r\n", # lowercase echo - b"MultidropCombi 2.00.29 836-4191\r\n", - b"VER END 0\r\n", - ] - result = await self.backend._send_command("VER") - self.assertEqual(result, ["MultidropCombi 2.00.29 836-4191"]) - - -class EnterRemoteModeTests(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - self.backend = MockCommunicationBackend() - - async def test_enter_remote_mode_success(self) -> None: - """Test successful VER command parses instrument info.""" - self.backend.io.readline.side_effect = [ - b"VER\r\n", - b"MultidropCombi 2.00.29 836-4191\r\n", - b"VER END 0\r\n", - ] - info = await self.backend._enter_remote_mode() - self.assertEqual(info["instrument_name"], "MultidropCombi") - self.assertEqual(info["firmware_version"], "2.00.29") - self.assertEqual(info["serial_number"], "836-4191") - - async def test_enter_remote_mode_retry_after_eak(self) -> None: - """Test VER retry after EAK when first VER fails.""" - call_count = 0 - - async def readline_side_effect() -> bytes: - nonlocal call_count - call_count += 1 - responses = [ - # First VER attempt - fails with error - b"VER\r\n", - b"VER END 1\r\n", - # EAK attempt - b"EAK\r\n", - b"EAK END 0\r\n", - # Second VER attempt - succeeds - b"VER\r\n", - b"MultidropCombi 2.00.29 836-4191\r\n", - b"VER END 0\r\n", - ] - if call_count <= len(responses): - return responses[call_count - 1] - return b"" - - self.backend.io.readline.side_effect = readline_side_effect - info = await self.backend._enter_remote_mode() - self.assertEqual(info["instrument_name"], "MultidropCombi") - - -class DrainStaleDataTests(unittest.IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - self.backend = MockCommunicationBackend() - - async def test_drain_with_stale_data(self) -> None: - """Test draining stale data from buffer.""" - self.backend.io.readline.side_effect = [ - b"stale line 1\r\n", - b"stale line 2\r\n", - b"", # No more data - ] - await self.backend._drain_stale_data() - self.backend.io.reset_input_buffer.assert_awaited_once() - self.backend.io.reset_output_buffer.assert_awaited_once() - - async def test_drain_empty_buffer(self) -> None: - """Test draining when buffer is already empty.""" - self.backend.io.readline.side_effect = [b""] - await self.backend._drain_stale_data() diff --git a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/helpers_tests.py b/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/helpers_tests.py deleted file mode 100644 index 0aeb2cd37dd..00000000000 --- a/pylabrobot/bulk_dispensers/thermo_scientific/multidrop_combi/tests/helpers_tests.py +++ /dev/null @@ -1,209 +0,0 @@ -import unittest - -from pylabrobot.bulk_dispensers.thermo_scientific.multidrop_combi.helpers import ( - plate_to_pla_params, - plate_to_type_index, - plate_well_count, -) -from pylabrobot.resources import Plate, Well, create_ordered_items_2d -from pylabrobot.resources.well import CrossSectionType, WellBottomType - - -def _make_plate( - num_items_x: int = 12, - num_items_y: int = 8, - size_z: float = 14.2, - well_max_volume: float = 360.0, - well_size_z: float = 10.67, -) -> Plate: - """Create a test plate with the given parameters.""" - return Plate( - name="test_plate", - size_x=127.76, - size_y=85.48, - size_z=size_z, - model="test", - ordered_items=create_ordered_items_2d( - Well, - num_items_x=num_items_x, - num_items_y=num_items_y, - dx=10.0, - dy=7.0, - dz=1.0, - item_dx=9.0, - item_dy=9.0, - size_x=6.0, - size_y=6.0, - size_z=well_size_z, - bottom_type=WellBottomType.FLAT, - cross_section_type=CrossSectionType.CIRCLE, - max_volume=well_max_volume, - ), - ) - - -class PlateToTypeIndexTests(unittest.TestCase): - """Test factory plate type mapping.""" - - def test_96_well_short(self): - plate = _make_plate(num_items_x=12, num_items_y=8, size_z=14.0) - self.assertEqual(plate_to_type_index(plate), 0) # 15mm type - - def test_96_well_medium(self): - plate = _make_plate(num_items_x=12, num_items_y=8, size_z=20.0) - self.assertEqual(plate_to_type_index(plate), 1) # 22mm type - - def test_96_well_tall(self): - plate = _make_plate(num_items_x=12, num_items_y=8, size_z=40.0) - self.assertEqual(plate_to_type_index(plate), 2) # 44mm type - - def test_384_well_very_short(self): - plate = _make_plate(num_items_x=24, num_items_y=16, size_z=7.0) - self.assertEqual(plate_to_type_index(plate), 3) # 7.5mm type - - def test_384_well_short(self): - plate = _make_plate(num_items_x=24, num_items_y=16, size_z=10.0) - self.assertEqual(plate_to_type_index(plate), 4) # 10mm type - - def test_384_well_medium(self): - plate = _make_plate(num_items_x=24, num_items_y=16, size_z=14.0) - self.assertEqual(plate_to_type_index(plate), 5) # 15mm type - - def test_384_well_tall(self): - plate = _make_plate(num_items_x=24, num_items_y=16, size_z=25.0) - self.assertEqual(plate_to_type_index(plate), 6) # 22mm type - - def test_384_well_very_tall(self): - plate = _make_plate(num_items_x=24, num_items_y=16, size_z=44.0) - self.assertEqual(plate_to_type_index(plate), 7) # 44mm type - - def test_1536_well_short(self): - plate = _make_plate(num_items_x=48, num_items_y=32, size_z=5.0) - self.assertEqual(plate_to_type_index(plate), 8) # 5mm type - - def test_1536_well_tall(self): - plate = _make_plate(num_items_x=48, num_items_y=32, size_z=10.0) - self.assertEqual(plate_to_type_index(plate), 9) # 10.5mm type - - def test_unsupported_well_count(self): - plate = _make_plate(num_items_x=6, num_items_y=4) # 24-well - with self.assertRaises(ValueError) as ctx: - plate_to_type_index(plate) - self.assertIn("24", str(ctx.exception)) - - def test_96_well_too_tall(self): - plate = _make_plate(num_items_x=12, num_items_y=8, size_z=60.0) - with self.assertRaises(ValueError): - plate_to_type_index(plate) - - -class PlateToTypeIndexRealPlatesTests(unittest.TestCase): - """Test with real PLR plate definitions.""" - - def test_corning_96_well(self): - from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb - - plate = Cor_96_wellplate_360ul_Fb("test") - self.assertEqual(plate_to_type_index(plate), 0) # 14.2mm → type 0 - - def test_biorad_384_well(self): - from pylabrobot.resources.biorad.plates import BioRad_384_wellplate_50uL_Vb - - plate = BioRad_384_wellplate_50uL_Vb("test") - self.assertEqual(plate_to_type_index(plate), 4) # 10.4mm → type 4 - - -class PlateToPlaParamsTests(unittest.TestCase): - """Test PLA command parameter generation.""" - - def test_96_well_params(self): - plate = _make_plate(num_items_x=12, num_items_y=8, size_z=14.2, well_max_volume=360.0) - params = plate_to_pla_params(plate) - self.assertEqual(params["columns"], 12) - self.assertEqual(params["rows"], 8) - self.assertEqual(params["column_positions"], 12) - self.assertEqual(params["row_positions"], 8) - self.assertEqual(params["height"], 1420) # 14.2mm * 100 - self.assertEqual(params["max_volume"], 3600) # 360uL * 10 - - def test_384_well_params(self): - plate = _make_plate(num_items_x=24, num_items_y=16, size_z=10.4, well_max_volume=50.0) - params = plate_to_pla_params(plate) - self.assertEqual(params["columns"], 24) - self.assertEqual(params["rows"], 16) - self.assertEqual(params["height"], 1040) - self.assertEqual(params["max_volume"], 500) - - def test_real_corning_96_well(self): - from pylabrobot.resources.corning.plates import Cor_96_wellplate_360ul_Fb - - plate = Cor_96_wellplate_360ul_Fb("test") - params = plate_to_pla_params(plate) - self.assertEqual(params["columns"], 12) - self.assertEqual(params["rows"], 8) - self.assertEqual(params["height"], 1420) - self.assertEqual(params["max_volume"], 3600) - - -class PlaParamsValidationTests(unittest.TestCase): - """Test parameter validation in plate_to_pla_params.""" - - def test_too_many_columns(self): - plate = _make_plate(num_items_x=49, num_items_y=8, size_z=14.0) - with self.assertRaises(ValueError) as ctx: - plate_to_pla_params(plate) - self.assertIn("49 columns", str(ctx.exception)) - self.assertIn("48", str(ctx.exception)) - - def test_too_many_rows(self): - plate = _make_plate(num_items_x=12, num_items_y=33, size_z=14.0) - with self.assertRaises(ValueError) as ctx: - plate_to_pla_params(plate) - self.assertIn("33 rows", str(ctx.exception)) - self.assertIn("32", str(ctx.exception)) - - def test_height_too_low(self): - plate = _make_plate(size_z=4.0) # 4mm < 5mm minimum - with self.assertRaises(ValueError) as ctx: - plate_to_pla_params(plate) - self.assertIn("4.0mm", str(ctx.exception)) - self.assertIn("minimum", str(ctx.exception)) - - def test_height_too_high(self): - plate = _make_plate(size_z=60.0) # 60mm > 55mm maximum - with self.assertRaises(ValueError) as ctx: - plate_to_pla_params(plate) - self.assertIn("60.0mm", str(ctx.exception)) - self.assertIn("maximum", str(ctx.exception)) - - def test_well_volume_too_high(self): - plate = _make_plate(well_max_volume=3000.0) # 3000uL > 2500uL max - with self.assertRaises(ValueError) as ctx: - plate_to_pla_params(plate) - self.assertIn("3000", str(ctx.exception)) - self.assertIn("2500", str(ctx.exception)) - - def test_height_at_minimum_boundary(self): - plate = _make_plate(size_z=5.0) # exactly 5mm = 500 hundredths - params = plate_to_pla_params(plate) - self.assertEqual(params["height"], 500) - - def test_height_at_maximum_boundary(self): - plate = _make_plate(size_z=55.0) # exactly 55mm = 5500 hundredths - params = plate_to_pla_params(plate) - self.assertEqual(params["height"], 5500) - - def test_volume_at_maximum_boundary(self): - plate = _make_plate(well_max_volume=2500.0) # exactly 2500uL - params = plate_to_pla_params(plate) - self.assertEqual(params["max_volume"], 25000) - - -class PlateWellCountTests(unittest.TestCase): - def test_96_well(self): - plate = _make_plate(num_items_x=12, num_items_y=8) - self.assertEqual(plate_well_count(plate), 96) - - def test_384_well(self): - plate = _make_plate(num_items_x=24, num_items_y=16) - self.assertEqual(plate_well_count(plate), 384) diff --git a/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py index 51b78ea0f23..37209e29045 100644 --- a/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py +++ b/pylabrobot/capabilities/automated_retrieval/automated_retrieval.py @@ -5,7 +5,10 @@ class AutomatedRetrieval(Capability): - """Automated plate retrieval/storage capability.""" + """Automated plate retrieval/storage capability. + + See :doc:`/user_guide/capabilities/automated-retrieval` for a walkthrough. + """ def __init__(self, backend: AutomatedRetrievalBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py b/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py index b627e70fcb8..00fbbc1a2d5 100644 --- a/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py +++ b/pylabrobot/capabilities/barcode_scanning/barcode_scanning.py @@ -5,7 +5,10 @@ class BarcodeScanner(Capability): - """Barcode scanning capability.""" + """Barcode scanning capability. + + See :doc:`/user_guide/capabilities/barcode-scanning` for a walkthrough. + """ def __init__(self, backend: BarcodeScannerBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/bulk_dispensers/peristaltic/__init__.py b/pylabrobot/capabilities/bulk_dispensers/peristaltic/__init__.py index bc4a60a4ea8..9d1cb966e78 100644 --- a/pylabrobot/capabilities/bulk_dispensers/peristaltic/__init__.py +++ b/pylabrobot/capabilities/bulk_dispensers/peristaltic/__init__.py @@ -1,2 +1,3 @@ from .backend import PeristalticDispensingBackend +from .chatterbox import PeristalticDispensingChatterboxBackend from .peristaltic import PeristalticDispensing diff --git a/pylabrobot/capabilities/bulk_dispensers/peristaltic/chatterbox.py b/pylabrobot/capabilities/bulk_dispensers/peristaltic/chatterbox.py new file mode 100644 index 00000000000..6a290310419 --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/peristaltic/chatterbox.py @@ -0,0 +1,41 @@ +import logging +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Plate + +from .backend import PeristalticDispensingBackend + +logger = logging.getLogger(__name__) + + +class PeristalticDispensingChatterboxBackend(PeristalticDispensingBackend): + """Chatterbox backend for device-free testing.""" + + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + logger.info("Dispensing volumes %s to plate '%s'.", volumes, plate.name) + + async def prime( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + logger.info("Priming peristaltic lines for plate '%s' (volume=%s, duration=%s).", + plate.name, volume, duration) + + async def purge( + self, + plate: Plate, + volume: Optional[float] = None, + duration: Optional[int] = None, + backend_params: Optional[BackendParams] = None, + ) -> None: + logger.info("Purging peristaltic lines for plate '%s' (volume=%s, duration=%s).", + plate.name, volume, duration) diff --git a/pylabrobot/capabilities/bulk_dispensers/peristaltic/peristaltic.py b/pylabrobot/capabilities/bulk_dispensers/peristaltic/peristaltic.py index 7df0211acf0..9ae48ef0f48 100644 --- a/pylabrobot/capabilities/bulk_dispensers/peristaltic/peristaltic.py +++ b/pylabrobot/capabilities/bulk_dispensers/peristaltic/peristaltic.py @@ -7,7 +7,10 @@ class PeristalticDispensing(Capability): - """Peristaltic dispensing capability.""" + """Peristaltic dispensing capability. + + See :doc:`/user_guide/capabilities/dispensing/peristaltic` for a walkthrough. + """ def __init__(self, backend: PeristalticDispensingBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/bulk_dispensers/syringe/__init__.py b/pylabrobot/capabilities/bulk_dispensers/syringe/__init__.py index d6d8305f50b..91626d9c67f 100644 --- a/pylabrobot/capabilities/bulk_dispensers/syringe/__init__.py +++ b/pylabrobot/capabilities/bulk_dispensers/syringe/__init__.py @@ -1,2 +1,3 @@ from .backend import SyringeDispensingBackend +from .chatterbox import SyringeDispensingChatterboxBackend from .syringe import SyringeDispensing diff --git a/pylabrobot/capabilities/bulk_dispensers/syringe/chatterbox.py b/pylabrobot/capabilities/bulk_dispensers/syringe/chatterbox.py new file mode 100644 index 00000000000..59111f430e9 --- /dev/null +++ b/pylabrobot/capabilities/bulk_dispensers/syringe/chatterbox.py @@ -0,0 +1,29 @@ +import logging +from typing import Dict, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.resources import Plate + +from .backend import SyringeDispensingBackend + +logger = logging.getLogger(__name__) + + +class SyringeDispensingChatterboxBackend(SyringeDispensingBackend): + """Chatterbox backend for device-free testing.""" + + async def dispense( + self, + plate: Plate, + volumes: Dict[int, float], + backend_params: Optional[BackendParams] = None, + ) -> None: + logger.info("Dispensing volumes %s to plate '%s'.", volumes, plate.name) + + async def prime( + self, + plate: Plate, + volume: float, + backend_params: Optional[BackendParams] = None, + ) -> None: + logger.info("Priming syringe pump for plate '%s' (volume=%s).", plate.name, volume) diff --git a/pylabrobot/capabilities/bulk_dispensers/syringe/syringe.py b/pylabrobot/capabilities/bulk_dispensers/syringe/syringe.py index 69fca39676b..6fcb83cbf6f 100644 --- a/pylabrobot/capabilities/bulk_dispensers/syringe/syringe.py +++ b/pylabrobot/capabilities/bulk_dispensers/syringe/syringe.py @@ -7,7 +7,10 @@ class SyringeDispensing(Capability): - """Syringe dispensing capability.""" + """Syringe dispensing capability. + + See :doc:`/user_guide/capabilities/dispensing/syringe` for a walkthrough. + """ def __init__(self, backend: SyringeDispensingBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/centrifuging/centrifuging.py b/pylabrobot/capabilities/centrifuging/centrifuging.py index 751a928f84b..783a7c86a17 100644 --- a/pylabrobot/capabilities/centrifuging/centrifuging.py +++ b/pylabrobot/capabilities/centrifuging/centrifuging.py @@ -8,7 +8,10 @@ class Centrifuge(Capability): - """Centrifuging capability.""" + """Centrifuging capability. + + See :doc:`/user_guide/capabilities/centrifuging` for a walkthrough. + """ def __init__( self, diff --git a/pylabrobot/capabilities/fan_control/fan_control.py b/pylabrobot/capabilities/fan_control/fan_control.py index 407cd7e8bbe..70180064c28 100644 --- a/pylabrobot/capabilities/fan_control/fan_control.py +++ b/pylabrobot/capabilities/fan_control/fan_control.py @@ -6,7 +6,10 @@ class Fan(Capability): - """Fan control capability.""" + """Fan control capability. + + See :doc:`/user_guide/capabilities/fan-control` for a walkthrough. + """ def __init__(self, backend: FanBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/humidity_controlling/humidity_controller.py b/pylabrobot/capabilities/humidity_controlling/humidity_controller.py index ab72799c572..10f2f5eade2 100644 --- a/pylabrobot/capabilities/humidity_controlling/humidity_controller.py +++ b/pylabrobot/capabilities/humidity_controlling/humidity_controller.py @@ -4,7 +4,10 @@ class HumidityController(Capability): - """Humidity control capability.""" + """Humidity control capability. + + See :doc:`/user_guide/capabilities/humidity-control` for a walkthrough. + """ def __init__(self, backend: HumidityControllerBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/liquid_handling/head96.py b/pylabrobot/capabilities/liquid_handling/head96.py index 5f53f5061c3..63ccee07137 100644 --- a/pylabrobot/capabilities/liquid_handling/head96.py +++ b/pylabrobot/capabilities/liquid_handling/head96.py @@ -37,6 +37,8 @@ class Head96(Capability): Faithfully ports the 96-head logic from the legacy LiquidHandler, including tip tracking with commit/rollback, volume tracking, partial tip pickup, single-container (trough) support, and convenience methods. + + See :doc:`/user_guide/capabilities/head96` for a walkthrough. """ def __init__(self, backend: Head96Backend, default_offset: Coordinate = Coordinate.zero()): diff --git a/pylabrobot/capabilities/liquid_handling/pip.py b/pylabrobot/capabilities/liquid_handling/pip.py index 2d5b89cf943..f2ec5275564 100644 --- a/pylabrobot/capabilities/liquid_handling/pip.py +++ b/pylabrobot/capabilities/liquid_handling/pip.py @@ -35,6 +35,8 @@ class PIP(Capability): Faithfully ports the tip tracking, volume tracking, validation, spread modes, and error handling from the legacy LiquidHandler frontend. + + See :doc:`/user_guide/capabilities/pip` for a walkthrough. """ def __init__(self, backend: PIPBackend): diff --git a/pylabrobot/capabilities/microscopy/microscopy.py b/pylabrobot/capabilities/microscopy/microscopy.py index ed1f3b7bf52..16079fd4376 100644 --- a/pylabrobot/capabilities/microscopy/microscopy.py +++ b/pylabrobot/capabilities/microscopy/microscopy.py @@ -62,6 +62,8 @@ class Microscopy(Capability): """Microscopy imaging capability. Provides high-level image capture with support for auto-exposure and auto-focus. + + See :doc:`/user_guide/capabilities/microscopy` for a walkthrough. """ def __init__(self, backend: MicroscopyBackend): diff --git a/pylabrobot/capabilities/peeling/peeling.py b/pylabrobot/capabilities/peeling/peeling.py index 2c1d1d71f15..422dfbf1351 100644 --- a/pylabrobot/capabilities/peeling/peeling.py +++ b/pylabrobot/capabilities/peeling/peeling.py @@ -7,7 +7,10 @@ class Peeler(Capability): - """Peeling capability.""" + """Peeling capability. + + See :doc:`/user_guide/capabilities/peeling` for a walkthrough. + """ def __init__(self, backend: PeelerBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py b/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py index 4eeb5343598..6ae2cc5dfda 100644 --- a/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py +++ b/pylabrobot/capabilities/plate_reading/absorbance/absorbance.py @@ -15,7 +15,10 @@ class Absorbance(Capability): - """Absorbance plate reading capability.""" + """Absorbance plate reading capability. + + See :doc:`/user_guide/capabilities/absorbance` for a walkthrough. + """ def __init__(self, backend: AbsorbanceBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py index 71079f5dd01..18e87a773fb 100644 --- a/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py +++ b/pylabrobot/capabilities/plate_reading/fluorescence/fluorescence.py @@ -15,7 +15,10 @@ class Fluorescence(Capability): - """Fluorescence plate reading capability.""" + """Fluorescence plate reading capability. + + See :doc:`/user_guide/capabilities/fluorescence` for a walkthrough. + """ def __init__(self, backend: FluorescenceBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py b/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py index e4b9785b106..d17dfd64ad9 100644 --- a/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py +++ b/pylabrobot/capabilities/plate_reading/luminescence/luminescence.py @@ -15,7 +15,10 @@ class Luminescence(Capability): - """Luminescence plate reading capability.""" + """Luminescence plate reading capability. + + See :doc:`/user_guide/capabilities/luminescence` for a walkthrough. + """ def __init__(self, backend: LuminescenceBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/pumping/pumping.py b/pylabrobot/capabilities/pumping/pumping.py index 82554ff55f2..b82abe7e46e 100644 --- a/pylabrobot/capabilities/pumping/pumping.py +++ b/pylabrobot/capabilities/pumping/pumping.py @@ -9,7 +9,10 @@ class Pump(Capability): - """Single-pump capability.""" + """Single-pump capability. + + See :doc:`/user_guide/capabilities/pumping` for a walkthrough. + """ def __init__( self, diff --git a/pylabrobot/capabilities/sealing/sealing.py b/pylabrobot/capabilities/sealing/sealing.py index 14f2a4b1ed8..b85a4ce0a73 100644 --- a/pylabrobot/capabilities/sealing/sealing.py +++ b/pylabrobot/capabilities/sealing/sealing.py @@ -4,7 +4,10 @@ class Sealer(Capability): - """Sealing capability.""" + """Sealing capability. + + See :doc:`/user_guide/capabilities/sealing` for a walkthrough. + """ def __init__(self, backend: SealerBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/shaking/shaking.py b/pylabrobot/capabilities/shaking/shaking.py index 88e2147f36e..aaa610ffce4 100644 --- a/pylabrobot/capabilities/shaking/shaking.py +++ b/pylabrobot/capabilities/shaking/shaking.py @@ -7,7 +7,10 @@ class Shaker(Capability): - """Shaking capability.""" + """Shaking capability. + + See :doc:`/user_guide/capabilities/shaking` for a walkthrough. + """ def __init__(self, backend: ShakerBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/temperature_controlling/temperature_controller.py b/pylabrobot/capabilities/temperature_controlling/temperature_controller.py index 0a9cd1bdb15..09225fa9c8c 100644 --- a/pylabrobot/capabilities/temperature_controlling/temperature_controller.py +++ b/pylabrobot/capabilities/temperature_controlling/temperature_controller.py @@ -8,7 +8,10 @@ class TemperatureController(Capability): - """Temperature control capability, for heating or cooling.""" + """Temperature control capability, for heating or cooling. + + See :doc:`/user_guide/capabilities/temperature-control` for a walkthrough. + """ def __init__(self, backend: TemperatureControllerBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/tilting/tilting.py b/pylabrobot/capabilities/tilting/tilting.py index 3bf500aea63..5da25864371 100644 --- a/pylabrobot/capabilities/tilting/tilting.py +++ b/pylabrobot/capabilities/tilting/tilting.py @@ -4,7 +4,10 @@ class Tilter(Capability): - """Tilting capability.""" + """Tilting capability. + + See :doc:`/user_guide/capabilities/tilting` for a walkthrough. + """ def __init__(self, backend: TilterBackend): super().__init__(backend=backend) diff --git a/pylabrobot/capabilities/weighing/weighing.py b/pylabrobot/capabilities/weighing/weighing.py index a88737bd5ce..d9935ad16ce 100644 --- a/pylabrobot/capabilities/weighing/weighing.py +++ b/pylabrobot/capabilities/weighing/weighing.py @@ -4,7 +4,10 @@ class Scale(Capability): - """Weighing capability.""" + """Weighing capability. + + See :doc:`/user_guide/capabilities/weighing` for a walkthrough. + """ def __init__(self, backend: ScaleBackend): super().__init__(backend=backend) diff --git a/pylabrobot/thermo_fisher/multidrop_combi/multidrop_combi.py b/pylabrobot/thermo_fisher/multidrop_combi/multidrop_combi.py index f3e8284b5dd..196770b57f3 100644 --- a/pylabrobot/thermo_fisher/multidrop_combi/multidrop_combi.py +++ b/pylabrobot/thermo_fisher/multidrop_combi/multidrop_combi.py @@ -28,7 +28,7 @@ def __init__( if driver is None: driver = MultidropCombiDriver(port=port, timeout=timeout) super().__init__(driver=driver) - self._driver: MultidropCombiDriver = driver + self.driver: MultidropCombiDriver = driver self.peristaltic = PeristalticDispensing( backend=MultidropCombiPeristalticDispensingBackend(driver) ) From fe39ca8842e26b034750016e16c0a96a0ed966fa Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 31 Mar 2026 23:21:34 -0700 Subject: [PATCH 31/69] Migrate analytical device docs to manufacturer-based layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cytation 5/1 → agilent/biotek/cytation/ - Synergy H1 → agilent/biotek/synergy_h1/ - CLARIOstar → bmg_labtech/clariostar/ - Byonoy Absorbance 96 → byonoy/absorbance_96/ - Byonoy Luminescence 96 → byonoy/luminescence_96/ - SpectraMax M5/384+ → molecular_devices/spectramax/ - ImageXpress Pico → molecular_devices/imageXpress/ - Add API RST files for BMG Labtech, Byonoy, Molecular Devices - Delete legacy docs and update toctrees Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/api/pylabrobot.agilent.rst | 3 + docs/api/pylabrobot.bmg_labtech.rst | 53 ++ docs/api/pylabrobot.byonoy.rst | 46 ++ docs/api/pylabrobot.molecular_devices.rst | 57 ++ docs/api/pylabrobot.rst | 3 + .../plate-reading/bmg-clariostar.ipynb | 324 --------- .../plate-reading/cytation.ipynb | 629 ------------------ .../02_analytical/plate-reading/pico.ipynb | 220 ------ .../plate-reading/plate-reading.ipynb | 2 +- .../plate-reading/synergyh1.ipynb | 285 -------- .../agilent/biotek/cytation/hello-world.ipynb | 137 ++++ docs/user_guide/agilent/biotek/index.md | 2 + .../biotek/synergy_h1/hello-world.ipynb | 179 +++++ .../bmg_labtech/clariostar/hello-world.ipynb | 204 ++++++ docs/user_guide/bmg_labtech/index.md | 7 + .../byonoy/absorbance_96/hello-world.ipynb | 105 +++ docs/user_guide/byonoy/index.md | 8 + .../byonoy/luminescence_96/hello-world.ipynb | 93 +++ docs/user_guide/index.md | 3 + .../molecular_devices/imageXpress/pico.ipynb | 227 +++++++ docs/user_guide/molecular_devices/index.md | 8 + .../spectramax/hello-world.ipynb | 278 ++++++++ migration-guide-for-claude.md | 1 + 23 files changed, 1415 insertions(+), 1459 deletions(-) create mode 100644 docs/api/pylabrobot.bmg_labtech.rst create mode 100644 docs/api/pylabrobot.byonoy.rst create mode 100644 docs/api/pylabrobot.molecular_devices.rst delete mode 100644 docs/user_guide/02_analytical/plate-reading/bmg-clariostar.ipynb delete mode 100644 docs/user_guide/02_analytical/plate-reading/cytation.ipynb delete mode 100644 docs/user_guide/02_analytical/plate-reading/pico.ipynb delete mode 100644 docs/user_guide/02_analytical/plate-reading/synergyh1.ipynb create mode 100644 docs/user_guide/agilent/biotek/cytation/hello-world.ipynb create mode 100644 docs/user_guide/agilent/biotek/synergy_h1/hello-world.ipynb create mode 100644 docs/user_guide/bmg_labtech/clariostar/hello-world.ipynb create mode 100644 docs/user_guide/bmg_labtech/index.md create mode 100644 docs/user_guide/byonoy/absorbance_96/hello-world.ipynb create mode 100644 docs/user_guide/byonoy/index.md create mode 100644 docs/user_guide/byonoy/luminescence_96/hello-world.ipynb create mode 100644 docs/user_guide/molecular_devices/imageXpress/pico.ipynb create mode 100644 docs/user_guide/molecular_devices/index.md create mode 100644 docs/user_guide/molecular_devices/spectramax/hello-world.ipynb diff --git a/docs/api/pylabrobot.agilent.rst b/docs/api/pylabrobot.agilent.rst index 21432b5bc10..53bd5568b67 100644 --- a/docs/api/pylabrobot.agilent.rst +++ b/docs/api/pylabrobot.agilent.rst @@ -103,6 +103,9 @@ BioTek Synergy H1 SynergyH1 SynergyH1Backend +.. autoclass:: pylabrobot.agilent.biotek.biotek.BioTekBackend.LuminescenceParams + :members: + VSpin ----- diff --git a/docs/api/pylabrobot.bmg_labtech.rst b/docs/api/pylabrobot.bmg_labtech.rst new file mode 100644 index 00000000000..6801505702e --- /dev/null +++ b/docs/api/pylabrobot.bmg_labtech.rst @@ -0,0 +1,53 @@ +.. currentmodule:: pylabrobot.bmg_labtech + +pylabrobot.bmg_labtech package +============================== + +CLARIOstar +---------- + +.. currentmodule:: pylabrobot.bmg_labtech.clariostar.clariostar + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CLARIOstar + +.. currentmodule:: pylabrobot.bmg_labtech.clariostar.driver + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CLARIOstarDriver + +.. currentmodule:: pylabrobot.bmg_labtech.clariostar.absorbance_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CLARIOstarAbsorbanceBackend + CLARIOstarAbsorbanceParams + +.. currentmodule:: pylabrobot.bmg_labtech.clariostar.fluorescence_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CLARIOstarFluorescenceBackend + +.. currentmodule:: pylabrobot.bmg_labtech.clariostar.luminescence_backend + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + CLARIOstarLuminescenceBackend diff --git a/docs/api/pylabrobot.byonoy.rst b/docs/api/pylabrobot.byonoy.rst new file mode 100644 index 00000000000..d8b47ad444a --- /dev/null +++ b/docs/api/pylabrobot.byonoy.rst @@ -0,0 +1,46 @@ +.. currentmodule:: pylabrobot.byonoy + +pylabrobot.byonoy package +========================= + +Absorbance 96 +------------- + +.. currentmodule:: pylabrobot.byonoy.absorbance_96 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ByonoyAbsorbance96 + ByonoyAbsorbance96Backend + ByonoyAbsorbanceBaseUnit + byonoy_a96a + byonoy_a96a_detection_unit + byonoy_a96a_illumination_unit + byonoy_a96a_parking_unit + byonoy_sbs_adapter + +Luminescence 96 +--------------- + +.. currentmodule:: pylabrobot.byonoy.luminescence_96 + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + ByonoyLuminescence96 + ByonoyLuminescence96Backend + ByonoyLuminescenceBaseUnit + byonoy_l96 + byonoy_l96_base_unit + byonoy_l96_reader_unit + byonoy_l96a + byonoy_l96a_base_unit + byonoy_l96a_reader_unit + +.. autoclass:: pylabrobot.byonoy.luminescence_96.ByonoyLuminescence96Backend.LuminescenceParams + :members: diff --git a/docs/api/pylabrobot.molecular_devices.rst b/docs/api/pylabrobot.molecular_devices.rst new file mode 100644 index 00000000000..97e144b6ca3 --- /dev/null +++ b/docs/api/pylabrobot.molecular_devices.rst @@ -0,0 +1,57 @@ +.. currentmodule:: pylabrobot.molecular_devices + +pylabrobot.molecular\_devices package +===================================== + +SpectraMax +---------- + +.. currentmodule:: pylabrobot.molecular_devices.spectramax + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + SpectraMaxM5 + SpectraMax384Plus + MolecularDevicesDriver + MolecularDevicesAbsorbanceBackend + SpectraMax384PlusAbsorbanceBackend + SpectraMaxM5FluorescenceBackend + SpectraMaxM5LuminescenceBackend + MolecularDevicesTemperatureBackend + MolecularDevicesSettings + ReadMode + ReadType + ReadOrder + Calibrate + CarriageSpeed + PmtGain + ShakeSettings + KineticSettings + SpectrumSettings + +.. autoclass:: pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesAbsorbanceBackend.AbsorbanceParams + :members: + +.. autoclass:: pylabrobot.molecular_devices.spectramax.spectramax_m5.SpectraMaxM5FluorescenceBackend.FluorescenceParams + :members: + +.. autoclass:: pylabrobot.molecular_devices.spectramax.spectramax_m5.SpectraMaxM5LuminescenceBackend.LuminescenceParams + :members: + + +ImageXpress Pico +---------------- + +.. currentmodule:: pylabrobot.molecular_devices.imageXpress.pico + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Pico + PicoDriver + PicoMicroscopyBackend diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index eef14e56dd1..9758a4a6a32 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -34,8 +34,11 @@ Manufacturers pylabrobot.agilent pylabrobot.azenta + pylabrobot.bmg_labtech + pylabrobot.byonoy pylabrobot.inheco pylabrobot.liconic pylabrobot.mettler_toledo + pylabrobot.molecular_devices pylabrobot.qinstruments pylabrobot.thermo_fisher diff --git a/docs/user_guide/02_analytical/plate-reading/bmg-clariostar.ipynb b/docs/user_guide/02_analytical/plate-reading/bmg-clariostar.ipynb deleted file mode 100644 index f561fa01c87..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/bmg-clariostar.ipynb +++ /dev/null @@ -1,324 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "18708b66", - "metadata": {}, - "source": [ - "# BMG Labtech CLARIOstar (Plus)\n", - "\n", - "| Summary | Photo |\n", - "|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|\n", - "| - [OEM Link](https://www.bmglabtech.com/en/clariostar-plus/)
    - **Communication Protocol / Hardware**: Serial (FTDI)/ USB-A
    - **Communication Level**: Firmware
    - **Measurement Modes**: Absorbance, Luminescence, Fluorescence
    - **Plate Delivery**: Loading tray
    - **Additional Standard Features**: Temperature sontrol, Shaking

    - **Additional Upgrades**: Injector system, increased max temperature, plate stacking system, ... | ![quadrants](img/bmg-labtech-clariostar-plus.png) |\n" - ] - }, - { - "cell_type": "markdown", - "id": "80e2e5dc", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)\n", - "\n", - "The CLARIOstar and CLARIOstar Plus require a minimum of two cable connections to be operational:\n", - "1. Power cord (standard IEC C13)\n", - "2. USB cable (USB-B with security screws at CLARIOstar end; USB-A at control PC end)\n", - "\n", - "Optional:\n", - "If you have a plate stacking unit to use with the CLARIOstar (Plus), an additional RS-232 port is available on the CLARIOstar (Plus).\n" - ] - }, - { - "cell_type": "markdown", - "id": "adb29364", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "34531f2c", - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "markdown", - "id": "e7a179e9", - "metadata": {}, - "source": [ - "To control the BMG Labtech CLARIOstar (Plus), generate a `PlateReader` frontend instance that uses a `CLARIOstarBackend` instance as its backend.\n", - "\n", - "To access the CLARIOstar-specific machine features you can still use the backend directly.\n", - "For convenience, it is useful to therefore store the backend instance as a separate `clariostar_backend` variable." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "363b8144", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading import PlateReader\n", - "\n", - "from pylabrobot.plate_reading.clario_star_backend import CLARIOstarBackend\n", - "clariostar_backend = CLARIOstarBackend()\n", - "\n", - "pr = PlateReader(\n", - " name=\"CLARIOstar\",\n", - " backend=clariostar_backend,\n", - " size_x=0.0, # TODO: generate new handling for resources with loading tray \n", - " size_y=0.0,\n", - " size_z=0.0\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "30720acb", - "metadata": {}, - "outputs": [], - "source": [ - "await pr.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "65555028", - "metadata": {}, - "source": [ - "```{note}\n", - "Expected behaviour: the machine should perform its initialization routine.\n", - "```" - ] - }, - { - "cell_type": "markdown", - "id": "7d2e9ed2", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Usage / Machine Features\n", - "\n", - "### Loading Tray" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c0834a6e", - "metadata": {}, - "outputs": [], - "source": [ - "await pr.open()" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "77c3406b", - "metadata": {}, - "outputs": [], - "source": [ - "# perform arm movement to move your plate of interest onto the CLARIOstar's loading tray\n", - "# this movement can be performed by a human\n", - "# or it can be performed by a robotic arm" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "092a02fa", - "metadata": {}, - "outputs": [], - "source": [ - "await pr.close()" - ] - }, - { - "cell_type": "markdown", - "id": "9c4d21b7", - "metadata": {}, - "source": [ - "### Set Temperature\n", - "\n", - "The CLARIOstar offers a temperature control feature.\n", - "Reaching a set temperature is relatively slow compared to standalone temperature controllers.\n", - "We therefore recommend setting the temperature early on in your automated Protocol (aP)." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "bb2de16b", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature exposure in active development" - ] - }, - { - "cell_type": "markdown", - "id": "bf4840ea", - "metadata": {}, - "source": [ - "### Set Shaking\n", - "\n", - "The CLARIOstar offers a shaking feature." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "60b71bea", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature in active development" - ] - }, - { - "cell_type": "markdown", - "id": "c4be6218", - "metadata": {}, - "source": [ - "---\n", - "### Measuring Absorbance\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e23acc3d", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature in active development including\n", - "# reading subsets of wells\n", - "# specifying orbital diameter\n", - "# specifying number of technical replicate measurements per well\n", - "# specifying start position for reading: topleft, topright, bottomleft, bottomright\n", - "# ...\n", - "\n", - "results_absorbance = await pr.read_absorbance()\n" - ] - }, - { - "cell_type": "markdown", - "id": "d9a13de2", - "metadata": {}, - "source": [ - "`results` will be a width x height array of absorbance values.\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "id": "e57e55de", - "metadata": {}, - "source": [ - "\n", - "#### Performing a Spectral Scan\n", - "\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8cae50a6", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature in active development" - ] - }, - { - "cell_type": "markdown", - "id": "c7a70d7e", - "metadata": {}, - "source": [ - "### Measuring Luminescence\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8656ef6", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature in active development\n", - "\n", - "results_luminescence = await pr.read_luminescence()" - ] - }, - { - "cell_type": "markdown", - "id": "8554a66c", - "metadata": {}, - "source": [ - "### Measuring Fluorescence\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "637f06a7", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature in active development" - ] - }, - { - "cell_type": "markdown", - "id": "362dd696", - "metadata": {}, - "source": [ - "### Using the Injector Needles\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cd676c58", - "metadata": {}, - "outputs": [], - "source": [ - "# WIP: feature in active development" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "plr", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.12.11" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/02_analytical/plate-reading/cytation.ipynb b/docs/user_guide/02_analytical/plate-reading/cytation.ipynb deleted file mode 100644 index 2958577ed71..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/cytation.ipynb +++ /dev/null @@ -1,629 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Cytation\n", - "\n", - "Cytation is an Agilent BioTek microplate reader / imager combination. This backend has been tested on the Cytation 1 and 5.\n", - "\n", - "See installation instructions `cytation-imager`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "from pylabrobot.plate_reading import ImageReader, ImagingMode, Objective\n", - "from pylabrobot.plate_reading import CytationBackend" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [], - "source": [ - "# for imaging, we need an environment variable to point to the Spinnaker GenTL file\n", - "import os\n", - "os.environ[\"SPINNAKER_GENTL64_CTI\"] = \"/usr/local/lib/spinnaker-gentl/Spinnaker_GenTL.cti\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pr = ImageReader(name=\"PR\", size_x=0,size_y=0,size_z=0, backend=CytationBackend())\n", - "await pr.setup(use_cam=True)" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'1320200 Version 2.07'" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await pr.backend.get_firmware_version()" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.open(slow=False)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Before closing, assign a plate to the plate reader. This determines the spacing of the loading tray in the machine, as well as the positioning of wells where spectrophotometric measurements and pictures will be taken." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb\n", - "plate = CellVis_24_wellplate_3600uL_Fb(name=\"plate\")\n", - "pr.assign_child_resource(plate)" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.close(slow=True)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Plate reading\n", - "\n", - "Note: these measurements were taken with a 96 well plate." - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "

    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "data = await pr.read_absorbance(wavelength=434)\n", - "plt.imshow(data)" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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4YRobm6OCRMmxHnnnRdPP/103rMKb/ny5VEqlWLJkiV5TymkF154IT75yU/GuHHjYsyYMXH00UfH448/nvesQunr64tLL700pk2bFmPGjIkjjzwyvvWtbw36Xg9EPPzww3HuuedGW1tblEqluPvuuwf8eZZlcdlll8XkyZNjzJgxMX/+/Hj22WfzGVtnCh0Wd911VyxdujQuv/zyeOKJJ+KYY46Js846K7Zt25b3tEJZs2ZNdHR0xNq1a+OBBx6IXbt2xZlnnhm9vb15TyusdevWxc033xyzZ8/Oe0ohvfzyyzFv3rw44IAD4t57740//vGP8d3vfjcOOeSQvKcVylVXXRUrVqyIG264If70pz/FVVddFVdffXVcf/31eU+re729vXHMMcfEjTfe+KZ/fvXVV8d1110XN910Uzz66KNx0EEHxVlnnRWvv17n7yS2L2QFduKJJ2YdHR395/v6+rK2trass7Mzx1XFt23btiwisjVr1uQ9pZC2b9+eHXXUUdkDDzyQfeADH8guvvjivCcVzle+8pXslFNOyXtG4Z199tnZhRdeOOCyj3zkI9nChQtzWlRMEZGtWrWq/3y1Ws0mTZqUfec73+m/7JVXXsnK5XL24x//OIeF9aWwz1js3Lkz1q9fH/Pnz++/rKGhIebPnx+PPPJIjsuKr7u7OyIixo4dm/OSYuro6Iizzz57wOcmtbnnnntizpw5cf7558eECRPi2GOPjVtvvTXvWYVz8sknx+rVq+OZZ56JiIjf//738Zvf/CYWLFiQ87Ji27RpU2zZsmXA13hra2vMnTvX95/I4U3IUnnppZeir68vJk6cOODyiRMnxp///OecVhVftVqNJUuWxLx582LWrFl5zymcO++8M5544olYt25d3lMK7bnnnosVK1bE0qVL42tf+1qsW7cuLrroohg1alQsWrQo73mF8dWvfjV6enpi+vTp0djYGH19fXHFFVfEwoUL855WaFu2bImIeNPvP//6s7ezwoYFI6OjoyOeeuqp+M1vfpP3lMLp6uqKiy++OB544IEYPXp03nMKrVqtxpw5c+LKK6+MiIhjjz02nnrqqbjpppuERQ1+8pOfxI9+9KNYuXJlzJw5MzZs2BBLliyJtrY29yMjprA/Cjn00EOjsbExtm7dOuDyrVu3xqRJk3JaVWyLFy+OX/7yl/HrX/96RN7afn+3fv362LZtWxx33HHR1NQUTU1NsWbNmrjuuuuiqakp+vr68p5YGJMnT44ZM2YMuOw973lPbN68OadFxfTlL385vvrVr8bHP/7xOProo+NTn/pUXHLJJdHZ2Zn3tEL71/cY33/eXGHDYtSoUXH88cfH6tWr+y+rVquxevXqOOmkk3JcVjxZlsXixYtj1apV8atf/SqmTZuW96RCOv300+PJJ5+MDRs29J/mzJkTCxcujA0bNkRjY2PeEwtj3rx5b/iV52eeeSYOO+ywnBYV06uvvhoNDQMf5hsbG6Narea0aP8wbdq0mDRp0oDvPz09PfHoo4/6/hMF/1HI0qVLY9GiRTFnzpw48cQT49prr43e3t644IIL8p5WKB0dHbFy5cr4+c9/Hs3Nzf0/I2xtbY0xY8bkvK44mpub3/C6lIMOOijGjRvn9So1uuSSS+Lkk0+OK6+8Mj760Y/GY489FrfcckvccssteU8rlHPPPTeuuOKKmDp1asycOTN+97vfxTXXXBMXXnhh3tPq3o4dO2Ljxo395zdt2hQbNmyIsWPHxtSpU2PJkiXx7W9/O4466qiYNm1aXHrppdHW1hbnnXdefqPrRd6/lrK3rr/++mzq1KnZqFGjshNPPDFbu3Zt3pMKJyLe9HT77bfnPa3w/Lrp8P3iF7/IZs2alZXL5Wz69OnZLbfckvekwunp6ckuvvjibOrUqdno0aOzI444Ivv617+eVSqVvKfVvV//+tdv+ri4aNGiLMv+41dOL7300mzixIlZuVzOTj/99Ozpp5/Od3Sd8LbpAEAyhX2NBQBQf4QFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMv8ftizzR1e5mXcAAAAASUVORK5CYII=", - "text/plain": [ - "
    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "data = await pr.read_fluorescence(\n", - " excitation_wavelength=485, emission_wavelength=528, focal_height=7.5\n", - ")\n", - "plt.imshow(data)" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "data = await pr.read_luminescence(focal_height=4.5)\n", - "plt.imshow(data)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Shaking" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.shake(\n", - " shake_type=CytationBackend.ShakeType.LINEAR,\n", - " frequency=4 # linear frequency in mm, 1 <= frequency <= 6\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Heating and cooling\n", - "\n", - "Cytation supports heating and active cooling." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.set_temperature(temperature=37) # Temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.get_current_temperature() # Returns temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.stop_heating_or_cooling() # Stop temperature control" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Imaging" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Installation\n", - "\n", - "See [Cytation imager installation instructions](https://docs.pylabrobot.org/user_guide/installation.html#cytation-imager)." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Usage\n", - "\n", - "Supported objectives:\n", - "\n", - "- `O_4x_PL_FL_PHASE`\n", - "- `O_20x_PL_FL_PHASE`\n", - "- `O_40x_PL_FL_PHASE`\n", - "\n", - "Supported imaging modes:\n", - "\n", - "- `C377_647`\n", - "- `C400_647`\n", - "- `C469_593`\n", - "- `ACRIDINE_ORANGE`\n", - "- `CFP`\n", - "- `CFP_FRET_V2`\n", - "- `CFP_YFP_FRET`\n", - "- `CFP_YFP_FRET_V2`\n", - "- `CHLOROPHYLL_A`\n", - "- `CY5`\n", - "- `CY5_5`\n", - "- `CY7`\n", - "- `DAPI`\n", - "- `GFP`\n", - "- `GFP_CY5`\n", - "- `OXIDIZED_ROGFP2`\n", - "- `PROPOIDIUM_IODIDE`\n", - "- `RFP`\n", - "- `RFP_CY5`\n", - "- `TAG_BFP`\n", - "- `TEXAS_RED`\n", - "- `YFP`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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WA0wnvrUpx40pAE1CnnoQx+67f9KjL785S/fO9ngpnBdW+3De/xRj3rUMdcsXN84nH/++Vsjm5qydMYf85jHbO2PJ5woWvG2t71ta3pUKl7NnS688MJbZY5HBnZHDF73utftTj311N3v/M7v7D7wgQ/snv70p+++6Iu+aPev//qvt/XQbjfw4he/ePc1X/M1u3/+538++Ln66qsPvn/GM56xu9e97rV7y1vesvuzP/uz3YMe9KDdt37rtx58/z//8z+7+9///ruzzjpr9+d//ue7yy67bHeXu9xl98IXvnB3MkN4+Mmf/MndRRddVErr7uKLLz7m+1/4hV/Y3fnOd95dcsklu7/8y7/cPfrRj97d5z732V177bUH1zzqUY/aPeABD9i9613v2v3RH/3R7n73u9/uB3/wBw++v+aaa3Zf+qVfunvCE56we//737977Wtfu/u8z/u83W/+5m/uTia4KVw/+clP3nA59/hHP/rRY65ZuL5xeOQjH7l71atetc39L/7iL3bf9V3ftfvyL//y3X/8x3+cUFrxN3/zN7vP//zP3z3nOc/ZffCDH9z92q/92u5Od7rT7vLLL7/V53x7hSPH6L/5m795d9555x38f9111+3ucY977M4///zbdFy3N0Yfgbs++NjHPrY75ZRTdm94wxsOPvurv/qrjZi+853v3P7vsN7xjnfc/cu//MvBNRdccMHuC7/wC3f//d//fSvM4H8/HGY+n/70p3d3u9vddr/4i794DK4/93M/d2MgQUSu+/70T//04Jo3velNuzvc4Q67f/qnf9r+f+UrX7k7/fTTj8HzC17wgt0ZZ5yxO1nhhhj9937v997gPQvXNx8+8pGPbDh7+9vffkJpxfOf//xN8Zjw+Mc/fhM0FhwfHCnTff153/Oe92wmz9ncpv/f+c533qZju71BJuPMnve9730382XmtSD81m954jizfj2y4bjfX/u1X7v1aQaPfOQjt25V9dBe8JlQD+96o0+81qwi19PEaybkb/qmbzq4puvb4+9+97sPrnnYwx629fSeuM+kWt/uBceagzMVn3HGGXvnnnvu1l8cLFzffKjP++wceqJoRdfMZ7hm0fTjhyPF6P/t3/5t77rrrjtm0wT9HxFdcHwQc8kHdvnll+9dcMEFGxPKD1k7xPAYYYsI3hCO+319a+C7BZ8J8HJje7ffMaYJn/M5n7MR1oX7mwf541/96lfvveUtb9l72ctetvf2t7997+yzz97oR7BwffPg05/+9N6znvWsvW/7tm/bu//97799dqJoxQ1dkzBw7bXX3qLzOipwZNvULvj/hwge+Lqv+7qN8d/73vfee/3rX78FiS1YcHuHH/iBHzj4O42yff4VX/EVm5b/iEc84jYd2+0RCrh7//vfv/eOd7zjth7KgqOu0d/lLnfZu9Od7vQZUZ39f7e73e02G9ftHZLIv+qrvmrvQx/60IbHXCQf+9jHbhDH/b6+NfDdgs8EeLmxvdvvj3zkI8d8X3Ry0eEL958d5KKKfrTHg4Xr44dnPvOZe2984xv33vrWt+7d8573PPj8RNGKG7qmbIileJyEjD4z0Td+4zdu5rhpUur/Bz/4wbfp2G7PUErRhz/84S3tK/yecsopx+A4n2Q+fDju9/ve975jCOUVV1yxHcyv/uqvvk3m8L8d7nOf+2wEbeI102T+4InXiGa+T3DVVVdtezyri2tKLcs3OnGfH/r000+/Ved0e4J//Md/3Hz07fFg4fqmoTjHmPzFF1+84aY9POFE0Yqumc9wzaLpNwN2RzC9rkjlCy+8cIuc/ZEf+ZEtvW5GdS64cXjuc5+7e9vb3rb727/9290f//Efb6kvpbwUVStlpjSaq666akuZefCDH7z9HE6Z+c7v/M4t7aY0mC/5ki856dPrPv7xj28pRP109H75l395+/vv//7vD9Lr2quXXnrp7r3vfe8WFX596XXf8A3fsHv3u9+9e8c73rH7yq/8ymNSvop0LuXriU984pb21HkoNelkSfk6Hlz33Y//+I9vkd/t8SuvvHJ35plnbrj8xCc+cfCMhesbh3PPPXdLB41WzDTF//qv/zq45kTQCul1z3ve87ao/Ve84hUrve5mwpFj9EF5lm2u8ulLtysPdsHxQ6krd7/73Tf8fdmXfdn2/4c+9KGD72M8P/qjP7qlFnUAH/vYx24HfMLf/d3f7c4+++wtrzghIeHhU5/61O5khre+9a0b0zn8U6qXFLuf+qmf2phHwuojHvGI3V//9V8f84x///d/35jNF3zBF2wpSD/0Qz+0Ma4J5eA/5CEP2Z7R+iVAnGxwY7iOEcVYYiilf9373vfe6m0cVgYWrm8crg+//ZRbf6JpRev59V//9RtNuu9973vMOxbcNKx+9AsWLFiwYMERhiPlo1+wYMGCBQsWHAuL0S9YsGDBggVHGBajX7BgwYIFC44wLEa/YMGCBQsWHGFYjH7BggULFiw4wrAY/YIFCxYsWHCEYTH6BQsWLFiw4AjDYvQLFixYsGDBEYbF6BcsWLBgwYIjDIvRL1iwYMGCBUcYFqNfsGDBggUL9o4u/F/GQ7DVqtyHzQAAAABJRU5ErkJggg==", - "text/plain": [ - "
    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "res = await pr.capture(\n", - " well=(1, 2),\n", - " mode=ImagingMode.BRIGHTFIELD,\n", - " objective=Objective.O_4X_PL_FL_Phase,\n", - " focal_height=0.833,\n", - " exposure_time=5,\n", - " gain=16,\n", - " led_intensity=10,\n", - ")\n", - "plt.imshow(res.images[0], cmap=\"gray\", vmin=0, vmax=255)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Autofocus\n", - "\n", - "Auto-focus can be configured with `pr.backend.set_auto_focus_search_range` where the parameters are the minimum and maximum focus heights in mm respectively." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 41, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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EDK9dfhXWGGFFVZYYTdDLruZAbffaPSq9SV9S9VYYe+DegVttJlassLehRXP+w5WXO8bWPH834P4Bndr7HeMnp83Jr9uooseVO1a4aKFFc/7DlZc7xtY8fzfwQVRZP9/BNcKK6ywwgorrLDrYGX0K6ywwgorrLCLYdcy+qISjznmmHMti7vC3oMVz/sGVjzvO1hxvW9gxfO+g10bdb/CCiussMIKK+xijX6FFVZYYYUVVlgZ/QorrLDCCivsalgZ/QorrLDCCivsYlgZ/QorrLDCCivsYlgZ/QorrLDCCivsYtiVjL6b87qhqssD7njHOy73Ra9wweElL3nJHnfA97rJTW6y/X0XXxx11FHLjU5d5NCVxK4QBZUevu9977tcINKlJEcfffT2RTT7K5x22mlb97vf/ZZyleH0Ix/5yB7flwDz4he/eLnopss2jjjiiOVSkQld8vGIRzxi+0rVxz/+8ds3LoLvfOc7W3e7292W/V+J0de85jVb+xv8J1x3rfbOPX6ve91rj2dWXJ8/HHvssctlMV2Q0xl/4AMfuFyPO2Fv0YpTTjll67a3ve2Sitdtcscff/w+meOugc0ugw984AOby172spt3vetdmzPOOGPzxCc+cXPlK1958+tf//riHtolBo455pjNzW52s80vf/nL7ddvfvOb7e+f/OQnb6597WtvTjzxxM3Xvva1zZ3udKfNne985+3v//nPf25ufvObb4444ojNN7/5zc0nP/nJ3A3zMwAABrtJREFUzSGHHLJ53vOet9mfITy84AUv2Hz4wx8upXVzwgkn7PH9q171qs2VrnSlzUc+8pHNt7/97c3973//zfWud73NX//61+1n7nWve21udatbbb785S9vPv/5z29ueMMbbh7+8Idvf//HP/5xc9WrXnXziEc8YnP66adv3v/+928uf/nLb97+9rdv9if4T7h+9KMfveBy7vHf//73ezyz4vr84Z73vOfm3e9+9zL3b33rW5v73Oc+m+tc5zqbP//5z3uVVvz4xz/eXOEKV9g885nP3Hzve9/bvOlNb9pc+tKX3nz605/e53O+pMKuY/R3uMMdNkcdddT2///6178217jGNTbHHnvsxTquSxqjj8CdG/zhD3/YHHjggZsPfehD2599//vfX4jpl770peX/DusBBxyw+dWvfrX9zHHHHbe54hWvuPn73/++D2bwvw87mc/ZZ5+9udrVrrZ57WtfuweuL3e5yy0MJIjI9buvfvWr28986lOf2lzqUpfa/PznP1/+f+tb37o5+OCD98Dzc5/73M2hhx662V/hvBj9Ax7wgPP8zYrrCw9nnXXWgrNTTz11r9KK5zznOYviMeGhD33oImiscMFgV5nuu8P361//+mLynJfb9P+XvvSli3VslzTIZJzZ8/rXv/5ivnQHd/jtbuSJ48z63ZsNx73f4ha3WO6+Bve85z2X26rOOOOMi2E2//tw5plnLnefT7x2WUWup4nXTMi3v/3tt5/p+fb4V77yle1n7n73uy93fU/cZ1LtTu8V9jQHZyo+9NBDt4488sjlPnOw4vrCQ/fOz5tD9xat6JnZhmdWmn7BYVcx+t/+9rdb//rXv/bYNEH/R0RXuGAQc8kH9ulPf3rruOOOW5hQfsiuQwyPEbaI4HnhuPdzWwPfrfDvAC/nt3d7jzFNuMxlLrMQ1hX3Fw7yx7/nPe/ZOvHEE7de/epXb5166qlb9773vRf6Eay4vnBw9tlnbz396U/fustd7rJ185vffPlsb9GK83omYeCvf/3rRTqv3QK79praFf7vEMEDt7zlLRfGf93rXnfrgx/84BIktsIKl3R42MMetv13GmX7/AY3uMGi5R9++OEX69guiVDA3emnn771hS984eIeygq7XaM/5JBDti596Uv/W1Rn/1/tale72MZ1SYck8hvf+MZbP/zhDxc85iL5wx/+cJ447v3c1sB3K/w7wMv57d3ezzrrrD2+Lzq56PAV9/8d5KKKfrTHgxXXFxye+tSnbn384x/fOvnkk7euda1rbX++t2jFeT1TNsSqeOyHjD4z0e1ud7vFHDdNSv1/2GGHXaxjuyRDKUU/+tGPlrSv8HvggQfugeN8kvnw4bj37373u3sQys9+9rPLwbzpTW96sczhfx2ud73rLQRt4jXTZP7gideIZr5PcNJJJy17PKuLZ0otyzc6cZ8f+uCDD96nc7okwc9+9rPFR98eD1Zc/2cozjEmf8IJJyy4aQ9P2Fu0omdmG55ZafqFgM0uTK8rUvn4449fImef9KQnLel1M6pzhfOHZz3rWZtTTjllc+aZZ26++MUvLqkvpbwUVStlpjSak046aUmZOeyww5bXzpSZe9zjHkvaTWkwV7nKVfb79Lo//elPSwpRr47e61//+uXvn/70p9vpde3Vj370o5vvfOc7S1T4uaXX3eY2t9l85Stf2XzhC1/Y3OhGN9oj5atI51K+HvnIRy5pT52HUpP2l5SvC4Lrvnv2s5+9RH63xz/3uc9tbnvb2y64/Nvf/rbdxorr84cjjzxySQeNVsw0xb/85S/bz+wNWiG97uijj16i9t/ylres6XUXEnYdow/Ks2xzlU9ful15sCtccCh15epXv/qCv2te85rL/z/84Q+3v4/xPOUpT1lSizqAD3rQg5YDPuEnP/nJ5t73vveSV5yQkPDwj3/8Y7M/w8knn7wwnZ2vUr2k2L3oRS9amEfC6uGHH775wQ9+sEcbv/vd7xZmc9BBBy0pSI997GMXxjWhHPy73vWuSxutXwLE/gbnh+sYUYwlhlL613Wve92l3sZOZWDF9fnDueG3V7n1e5tWtJ63vvWtF5p0/etff48+VvjPsN5Hv8IKK6ywwgq7GHaVj36FFVZYYYUVVtgTVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCCrsYVka/wgorrLDCClu7F/4fbsHV1Hx2UAEAAAAASUVORK5CYII=", - "text/plain": [ - "
    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "from pylabrobot.plate_reading.standard import AutoFocus\n", - "from pylabrobot.plate_reading.imager import evaluate_focus_nvmg_sobel\n", - "\n", - "res = await pr.capture(\n", - " well=(1, 2),\n", - " mode=ImagingMode.BRIGHTFIELD,\n", - " objective=Objective.O_4X_PL_FL_Phase,\n", - " focal_height=AutoFocus(\n", - " low=1.8,\n", - " high=2.0,\n", - " evaluate_focus=evaluate_focus_nvmg_sobel,\n", - " timeout=30,\n", - " ),\n", - " exposure_time=5,\n", - " gain=16,\n", - " led_intensity=10\n", - ")\n", - "plt.imshow(res.images[0], cmap=\"gray\", vmin=0, vmax=255)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Autoexposure" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Two autoexposure functions are available in the PLR library:\n", - "- `max_pixel_at_fraction`: the value of the highest pixel in the image is a fraction of the maximum possible value (e.g. highest value is 50% of max, which would be 255/2 = 127.5 in the case of an 8 bit image)\n", - "- `fraction_overexposed`: the fraction of pixels at the cap (eg 255 for an 8 bit image) should be a certain fraction of the total number of pixels (e.g. 0.5% of pixels should be at the cap, so 0.005 * total_pixels). This is useful for images that are not well illuminated, as it ensures that a certain fraction of pixels is overexposed, which can help with image quality.\n", - "\n", - "You can also define your own autoexposure function.\n", - "\n", - "The `AutoExposure` dataclass is used to configure the autoexposure settings, including the evaluation function, maximum number of rounds, and low and high exposure time limits." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading.imager import Imager, max_pixel_at_fraction, fraction_overexposed\n", - "from pylabrobot.plate_reading.standard import AutoExposure\n", - "\n", - "res = await pr.capture(\n", - " exposure_time=AutoExposure(\n", - " # evaluate_exposure=fraction_overexposed(fraction=0.005, margin=0.005/10),\n", - " evaluate_exposure=max_pixel_at_fraction(fraction=0.90, margin=0.05),\n", - " max_rounds=15,\n", - " low=1,\n", - " high=100\n", - " ),\n", - " well=(2, 2),\n", - " mode=ImagingMode.PHASE_CONTRAST,\n", - " objective=Objective.O_20X_PL_FL_Phase,\n", - " focal_height=1.8, # focal height must be specified when using auto exposure\n", - " gain=20 # gain must be specified when using auto exposure\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Exporting\n", - "\n", - "`.capture` returns a `List[Image]` where `Image = List[List[float]]` where each item is `0 <= x <= 255`. You can export this to an image file in many ways. Here's one example of exporting to a 16-bit tiff:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from PIL import Image\n", - "import numpy as np\n", - "\n", - "array = np.array(res.images[0], dtype=np.float32)\n", - "array_uint16 = (array * (65535 / 255)).astype(np.uint16)\n", - "Image.fromarray(array_uint16).save(\"test.tiff\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### Coverage\n", - "\n", - "Use the `coverage` parameter to take multiple pictures of the same well. The `coverage` parameter is an tuple `(num_rows, num_columns)` or `\"full\"`.\n", - "\n", - "When we send the exact same commands as gen5.exe, with overlap = 0, we still get some overlap in the resulting images. This is probably because gen5.exe crops. For now, we don't support stitching or cropping in PLR yet, but we will in the future." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "16" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "num_rows = 4\n", - "num_cols = 4\n", - "\n", - "res = await pr.capture(\n", - " well=(1, 2),\n", - " mode=ImagingMode.BRIGHTFIELD,\n", - " objective=Objective.O_4X_PL_FL_Phase,\n", - " focal_height=0.833,\n", - " exposure_time=5,\n", - " gain=16,\n", - " coverage=(num_rows, num_cols),\n", - " center_position=(-6, 0),\n", - ")\n", - "len(res.images)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [ - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA4sAAAJ8CAYAAABX8zR2AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlHJYcgAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs/dnTftt61/XvDbHv+77vSTCmIQnpk52AUFGkkcKyL/8APLYsqzy0PLA80CqrLPQAqdIiGGJCIAkhhCR7JwQSENDYYoMtKvYdsH/1fszr+X32yLy/37XW8+y119prXlV33fc955hjjjnHNca4Plc3Pv7pT3/60x+76aabbrrppptuuummm2666aahn7N/brrppptuuummm2666aabbropusHiTTfddNNNN91000033XTTTT+LbrB400033XTTTTfddNNNN91008+iGyzedNNNN91000033XTTTTfd9LPoBos33XTTTTfddNNNN9100003/Sy6weJNN91000033XTTTTfddNNNP4tusHjTTTfddNNNN91000033XTTz6IbLN5000033XTTTTfddNNNN930s+gGizfddNNNN91000033XTTTTf9LPqCj71D+vP+vD/vnRa96bNAH//4x58+P/fn/tyPffrTn/6MY1HH+vycn/P/4f8/8Sf+xNM5ZVH/XfOme6Hq+5N/8k/+rHNf8AVf8HTuj//xP/4Z7al9ff7f//f/fWvdfnuOs61nuT/yR/7IG9t90zujv/Av/As/493Wl3hmqXNX/a+vzr48+epRX1+VuSp79Xt5/Ly+T88Q/11de967svvsf8qf8qe89Rm3Hd5XdWz5R8/YWFH2fKfG9TmOle27MfWn/ql/6vO1ykb7zB3rXj2P3//X//V/PZVxvrr+rD/rz/qMZ/WpfvV1bbTvXJn/7X/7357u8af9aX/aZ4zf/+f/+X+enlN9O0f5/umf/unPeEc3vXv6wi/8ws/ol6h+6f3iJzwTnXNsZesn1yA80XE8G20Z88Ly4PJR1PjAy9WBl7auzim342brOe99/l++8r1t2bY6j7+N4bPcWef5fI/WqvP+u1477h7m10fPfHX/d/p+3nb8teh//p//589q/R8V+vP//D//fe+7m256N2P5HYPFmz43ZIEg6O0xQmMLzpVguwvgudCcgPBqcdx7bPldaM+6Kn91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AHtr33X97QBs9DKPszEeoRfDLjV9RuTtNVoqLTPHQw6ilJwaRqGJJz3pSQeBx1hFz3lCzT+nT+8Ej+FpZ6blTCnKAe+sh3fI255zBKREZ0h3pmGpoh1J4Lu6T3ZOVEZTDT6KHJTpEI8r5a2zDcvYKH238Uv/6TzUsgnMzfv7XfTfPQm4mTZTmrBx6sTq/e2td8ff8As81vd1Nq2OJOdWKbspBDm86oKdEls2Rc66jIsFp0N8HX5W11ojJbiBNjrLs26Z9jvZxwgqktzxRnVDrENwipbvjNEz3JeDMEMoBa3IfHNr3+vcW0fT0h89h3z2XfK4YzXQF7ncuY/kCPn61re+9WBQyizwTIYl3ILLxnzqU5+6GdLGrKmVxlpFb9Bv5xrmNPGuInIdO0AGmTveYl6a23GYGn+mYM+jtvxNhpfVI2rovpRB72GMnNLePSO+8bwXuckJ3HFe8a+nPe1pmyFsbVJe61WAR9JDqkndK885rz0bX+xoAjSb4e4+EUzzLjXe+xrf+pc1tbIEzgdlwmWc5KChQzmSgvHmbEuf51iYDabCgYwN/Jmjoo65vkNHZX7RwXzu/5yHnRYAP+FXjRfDEc+uOVPR5eRd0VByD4+Ivgpc5Oz1veaQNcCpN0IBoFLkc0yVlYP+So83fk7XGcUMaq5YXT6dNCPa//SC6COnyDQYp87ce37iZZumz64Gs2Zw0l4yL1kOes5srHO1COJMR73o888NN9y9tPAFCxYsWLBgwYIFCxYsWHBKg5sFCxYsWLBgwYIFCxYsWPAjA5axuGDBggULFixYsGDBggULroBlLC5YsGDBggULFixYsGDBgitgGYsLFixYsGDBggULFixYsOAKWMbiggULFixYsGDBggULFiy4ApaxuGDBggULFixYsGDBggULroBlLC5YsGDBggULFixYsGDBgitgGYsLFixYsGDBggULFixYsOAKWMbiggULFixYsGDBggULFiy4ApaxuGDBggULFixYsGDBggULroBlLC5YsGDBggULFixYsGDBgitgGYsLFixYsGDBggULFixYsOAKWMbiggULFixYsGDBggULFiy4ApaxuGDBggULFixYsGDBggULroBlLC5YsGDBggULFixYsGDBgitgGYsLFixYsGDBggULFixYsOAKWMbiggULFixYsGDBggULFiy4ApaxuGDBggUL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", - "text/plain": [ - "
    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "fig = plt.figure(figsize=(12, 8))\n", - "for row in range(num_rows):\n", - " for col in range(num_cols):\n", - " plt.subplot(num_rows, num_cols, row * num_cols + col + 1)\n", - " plt.imshow(res.images[row * num_cols + col], cmap=\"gray\", vmin=0, vmax=255)\n", - " plt.axis(\"off\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "#### How long does it take to capture an image?" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2089.59 ms ± 15.72 ms\n", - "Overhead: 185.59 ms\n" - ] - } - ], - "source": [ - "import time\n", - "import numpy as np\n", - "\n", - "exposure_time = 1904\n", - "\n", - "# first time setting imaging mode is slower\n", - "_ = await pr.capture(well=(1, 1), mode=ImagingMode.BRIGHTFIELD, focal_height=3.3, exposure_time=exposure_time)\n", - "\n", - "l = []\n", - "for i in range(10):\n", - " t0 = time.monotonic_ns()\n", - " _ = await pr.capture(well=(1, 1), mode=ImagingMode.BRIGHTFIELD, focal_height=3.3, exposure_time=exposure_time)\n", - " t1 = time.monotonic_ns()\n", - " l.append((t1 - t0) / 1e6)\n", - "\n", - "print(f\"{np.mean(l):.2f} ms ± {np.std(l):.2f} ms\")\n", - "print(f\"Overhead: {(np.mean(l) - exposure_time):.2f} ms\")" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/user_guide/02_analytical/plate-reading/pico.ipynb b/docs/user_guide/02_analytical/plate-reading/pico.ipynb deleted file mode 100644 index bd94ad3b032..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/pico.ipynb +++ /dev/null @@ -1,220 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# ImageXpress Pico\n", - "\n", - "The [Molecular Devices ImageXpress Pico](https://www.moleculardevices.com/products/cellular-imaging-systems/high-content-imaging/imagexpress-pico) is an automated cell imaging system. PyLabRobot communicates with it over SiLA 2 / gRPC.\n", - "\n", - "## Requirements\n", - "\n", - "- The Pico must be running the SiLA 2 server (default port 8091).\n", - "- `pip install pylabrobot[sila] numpy Pillow`" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Setup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.plate_reading import Imager, ImagingMode, Objective\n", - "from pylabrobot.microscopes.molecular_devices.pico.backend import ExperimentalPicoBackend" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Create the backend, specifying which objectives and filter cubes are physically installed on your instrument. The keys are 0-indexed turret / filter-wheel positions." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "backend = ExperimentalPicoBackend(\n", - " host=\"192.168.1.100\",\n", - " objectives={0: Objective.O_4X_PL_FL},\n", - " filter_cubes={0: ImagingMode.DAPI, 1: ImagingMode.BRIGHTFIELD},\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pico = Imager(name=\"pico\", size_x=0, size_y=0, size_z=0, backend=backend)\n", - "await pico.setup()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Assign a plate\n", - "\n", - "The plate geometry is used to derive the labware parameters sent to the Pico. Any PLR `Plate` definition works." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import CellVis_96_wellplate_350uL_Fb\n", - "\n", - "plate = CellVis_96_wellplate_350uL_Fb(name=\"plate\")\n", - "pico.assign_child_resource(plate)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Loading a plate\n", - "\n", - "Open the plate drawer, place the plate, and close it." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pico.backend.open_door()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Place the plate in the drawer, then close it.\n", - "await pico.backend.close_door()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Capture an image\n", - "\n", - "Supported objectives:\n", - "\n", - "- `O_2_5X_N_PLAN` — N PLAN 2.5x/0.07\n", - "- `O_4X_PL_FL` — PL FLUOTAR 4x/0.13\n", - "- `O_10X_PL_FL` — PL FLUOTAR 10x/0.30\n", - "- `O_20X_PL_FL` — PL FLUOTAR 20x/0.40\n", - "- `O_40X_PL_FL` — PL FLUOTAR 40x/0.60\n", - "\n", - "Supported imaging modes:\n", - "\n", - "- `BRIGHTFIELD`\n", - "- `DAPI`\n", - "- `GFP`\n", - "- `RFP`\n", - "- `TEXAS_RED`\n", - "- `CY5`" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "res = await pico.capture(\n", - " well=(0, 0), # A1\n", - " mode=ImagingMode.BRIGHTFIELD,\n", - " objective=Objective.O_4X_PL_FL,\n", - " exposure_time=10.0, # ms\n", - " focal_height=1.0, # mm\n", - " gain=0,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "\n", - "plt.imshow(res.images[0], cmap=\"gray\")\n", - "plt.title(f\"Exposure: {res.exposure_time:.1f} ms\")\n", - "plt.axis(\"off\")\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "You can also pass a `Well` object directly:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "res = await pico.capture(\n", - " well=plate.get_well(\"B3\"),\n", - " mode=ImagingMode.DAPI,\n", - " objective=Objective.O_4X_PL_FL,\n", - " exposure_time=15.0,\n", - " focal_height=1.0,\n", - " gain=0,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Cleanup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pico.stop()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.10.0" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb b/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb index f30446d6fc1..a46f534f82c 100644 --- a/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb +++ b/docs/user_guide/02_analytical/plate-reading/plate-reading.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "id": "39d0c1a5", "metadata": {}, - "source": "# Plate reading\n\nPyLabRobot supports the following plate readers:\n\n```{toctree}\n:maxdepth: 1\n\nbmg-clariostar\nbyonoy/absorbance\nbyonoy/luminescence\nbyonoy\ncytation\npico\nsynergyh1\ntecan-spark\ntecan-infinite\n```\n\nThis example uses the `PlateReaderChatterboxBackend`. When using a real machine, use the corresponding backend." + "source": "# Plate reading\n\nPyLabRobot supports the following plate readers:\n\n```{toctree}\n:maxdepth: 1\n\nbyonoy/absorbance\nbyonoy/luminescence\nbyonoy\ntecan-spark\ntecan-infinite\n```\n\nThis example uses the `PlateReaderChatterboxBackend`. When using a real machine, use the corresponding backend." }, { "cell_type": "code", diff --git a/docs/user_guide/02_analytical/plate-reading/synergyh1.ipynb b/docs/user_guide/02_analytical/plate-reading/synergyh1.ipynb deleted file mode 100644 index 8821545e00f..00000000000 --- a/docs/user_guide/02_analytical/plate-reading/synergyh1.ipynb +++ /dev/null @@ -1,285 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": "# Synergy H1\n\n## Installation\n\n```bash\npip install pylabrobot[ftdi]\n```\n\nSynergy H1 is an Agilent BioTek microplate reader that can read absorbance, fluorescence, and luminescence. Please refer to the [user guide](https://cqls.oregonstate.edu/sites/cqls.oregonstate.edu/files/synergy_h1_user_manual_sd-xb000426.pdf) for installation instructions." - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "from pylabrobot.plate_reading import PlateReader\n", - "from pylabrobot.plate_reading import SynergyH1Backend" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "pr = PlateReader(name=\"PR\", size_x=0,size_y=0,size_z=0, backend=SynergyH1Backend())\n", - "await pr.setup()" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'1320200 Version 2.07'" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "await pr.backend.get_firmware_version()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.open()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Before closing, assign a plate to the plate reader. This determines the spacing of the loading tray in the machine, as well as the positioning of wells where spectrophotometric measurements and pictures will be taken." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.resources import CellVis_24_wellplate_3600uL_Fb\n", - "plate = CellVis_24_wellplate_3600uL_Fb(name=\"plate\")\n", - "pr.assign_child_resource(plate)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.close()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Plate reading\n", - "\n", - "Note: these measurements were taken with a 96 well plate." - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "data = await pr.read_absorbance(wavelength=434)\n", - "plt.imshow(data)" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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4YRobm6OCRMmxHnnnRdPP/103rMKb/ny5VEqlWLJkiV5TymkF154IT75yU/GuHHjYsyYMXH00UfH448/nvesQunr64tLL700pk2bFmPGjIkjjzwyvvWtbw36Xg9EPPzww3HuuedGW1tblEqluPvuuwf8eZZlcdlll8XkyZNjzJgxMX/+/Hj22WfzGVtnCh0Wd911VyxdujQuv/zyeOKJJ+KYY46Js846K7Zt25b3tEJZs2ZNdHR0xNq1a+OBBx6IXbt2xZlnnhm9vb15TyusdevWxc033xyzZ8/Oe0ohvfzyyzFv3rw44IAD4t57740//vGP8d3vfjcOOeSQvKcVylVXXRUrVqyIG264If70pz/FVVddFVdffXVcf/31eU+re729vXHMMcfEjTfe+KZ/fvXVV8d1110XN910Uzz66KNx0EEHxVlnnRWvv17n7yS2L2QFduKJJ2YdHR395/v6+rK2trass7Mzx1XFt23btiwisjVr1uQ9pZC2b9+eHXXUUdkDDzyQfeADH8guvvjivCcVzle+8pXslFNOyXtG4Z199tnZhRdeOOCyj3zkI9nChQtzWlRMEZGtWrWq/3y1Ws0mTZqUfec73+m/7JVXXsnK5XL24x//OIeF9aWwz1js3Lkz1q9fH/Pnz++/rKGhIebPnx+PPPJIjsuKr7u7OyIixo4dm/OSYuro6Iizzz57wOcmtbnnnntizpw5cf7558eECRPi2GOPjVtvvTXvWYVz8sknx+rVq+OZZ56JiIjf//738Zvf/CYWLFiQ87Ji27RpU2zZsmXA13hra2vMnTvX95/I4U3IUnnppZeir68vJk6cOODyiRMnxp///OecVhVftVqNJUuWxLx582LWrFl5zymcO++8M5544olYt25d3lMK7bnnnosVK1bE0qVL42tf+1qsW7cuLrroohg1alQsWrQo73mF8dWvfjV6enpi+vTp0djYGH19fXHFFVfEwoUL855WaFu2bImIeNPvP//6s7ezwoYFI6OjoyOeeuqp+M1vfpP3lMLp6uqKiy++OB544IEYPXp03nMKrVqtxpw5c+LKK6+MiIhjjz02nnrqqbjpppuERQ1+8pOfxI9+9KNYuXJlzJw5MzZs2BBLliyJtrY29yMjprA/Cjn00EOjsbExtm7dOuDyrVu3xqRJk3JaVWyLFy+OX/7yl/HrX/96RN7afn+3fv362LZtWxx33HHR1NQUTU1NsWbNmrjuuuuiqakp+vr68p5YGJMnT44ZM2YMuOw973lPbN68OadFxfTlL385vvrVr8bHP/7xOProo+NTn/pUXHLJJdHZ2Zn3tEL71/cY33/eXGHDYtSoUXH88cfH6tWr+y+rVquxevXqOOmkk3JcVjxZlsXixYtj1apV8atf/SqmTZuW96RCOv300+PJJ5+MDRs29J/mzJkTCxcujA0bNkRjY2PeEwtj3rx5b/iV52eeeSYOO+ywnBYV06uvvhoNDQMf5hsbG6Narea0aP8wbdq0mDRp0oDvPz09PfHoo4/6/hMF/1HI0qVLY9GiRTFnzpw48cQT49prr43e3t644IIL8p5WKB0dHbFy5cr4+c9/Hs3Nzf0/I2xtbY0xY8bkvK44mpub3/C6lIMOOijGjRvn9So1uuSSS+Lkk0+OK6+8Mj760Y/GY489FrfcckvccssteU8rlHPPPTeuuOKKmDp1asycOTN+97vfxTXXXBMXXnhh3tPq3o4dO2Ljxo395zdt2hQbNmyIsWPHxtSpU2PJkiXx7W9/O4466qiYNm1aXHrppdHW1hbnnXdefqPrRd6/lrK3rr/++mzq1KnZqFGjshNPPDFbu3Zt3pMKJyLe9HT77bfnPa3w/Lrp8P3iF7/IZs2alZXL5Wz69OnZLbfckvekwunp6ckuvvjibOrUqdno0aOzI444Ivv617+eVSqVvKfVvV//+tdv+ri4aNGiLMv+41dOL7300mzixIlZuVzOTj/99Ozpp5/Od3Sd8LbpAEAyhX2NBQBQf4QFAJCMsAAAkhEWAEAywgIASEZYAADJCAsAIBlhAQAkIywAgGSEBQCQjLAAAJIRFgBAMv8ftizzR1e5mXcAAAAASUVORK5CYII=", - "text/plain": [ - "
    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "data = await pr.read_fluorescence(\n", - " excitation_wavelength=485, emission_wavelength=528, focal_height=7.5\n", - ")\n", - "plt.imshow(data)" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
    " - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "data = await pr.read_luminescence(focal_height=4.5)\n", - "plt.imshow(data)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Shaking" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.shake(\n", - " shake_type=SynergyH1Backend.ShakeType.LINEAR,\n", - " frequency=4 # linear frequency in mm, 1 <= frequency <= 6\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.stop_shaking()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Heating\n", - "\n", - "Synergy H1 supports heating but does not support active cooling." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.set_temperature(temperature=37) # Temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.get_current_temperature() # Returns temperature in degrees C" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await pr.backend.stop_heating_or_cooling() # Stop temperature control" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} \ No newline at end of file diff --git a/docs/user_guide/agilent/biotek/cytation/hello-world.ipynb b/docs/user_guide/agilent/biotek/cytation/hello-world.ipynb new file mode 100644 index 00000000000..81a82daa89f --- /dev/null +++ b/docs/user_guide/agilent/biotek/cytation/hello-world.ipynb @@ -0,0 +1,137 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "wez9bxdabm", + "source": "# Agilent BioTek Cytation\n\nThe Cytation is an Agilent BioTek multi-mode plate reader with optional microscopy imaging. Depending on the model it supports:\n\n- [Absorbance](../../../capabilities/absorbance)\n- [Fluorescence](../../../capabilities/fluorescence)\n- [Luminescence](../../../capabilities/luminescence)\n- [Microscopy](../../../capabilities/microscopy)\n- [Temperature control](../../../capabilities/temperature-control)\n\n| Model | PLR Name | Plate Reading | Microscopy | Temperature |\n|---|---|---|---|---|\n| Cytation 5 | `Cytation5` | Absorbance, Fluorescence, Luminescence | yes | yes |\n| Cytation 1 | `Cytation1` | -- | yes | yes |\n\nBoth models share the `CytationBackend` driver, which communicates over FTDI USB. The Cytation 5 adds plate-reading capabilities on top of the shared microscopy and temperature-control features.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "0rn94ubvq8dj", + "source": "## Setup\n\nThe examples below use a Cytation 5. For a Cytation 1, replace `Cytation5` with `Cytation1` (the Cytation 1 does not have `.absorbance`, `.fluorescence`, or `.luminescence` attributes).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ia4t5ga2ldg", + "source": "from pylabrobot.agilent.biotek.cytation import Cytation5\n\nc5 = Cytation5(name=\"cytation5\")\nawait c5.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "35rmpdivj44", + "source": "Open and close the tray door. Pass `slow=True` for slower motor travel if needed.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "l85qt1z6hdf", + "source": "await c5.open()\n\nfrom pylabrobot.resources import Cor_96_wellplate_360ul_Fb\nplate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\nc5.plate_holder.assign_child_resource(plate)\n\nawait c5.close()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "hxkf2luxk9n", + "source": "## Plate reading (Cytation 5 only)\n\nThe Cytation 5 exposes `.absorbance`, `.fluorescence`, and `.luminescence` capability objects. For the full API, see [Absorbance](../../../capabilities/absorbance), [Fluorescence](../../../capabilities/fluorescence), and [Luminescence](../../../capabilities/luminescence).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "hwipa2rwkzl", + "source": "# Absorbance\ndata = await c5.absorbance.read_absorbance(wavelength=450)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "jmvn8du2t5", + "source": "# Fluorescence\ndata = await c5.fluorescence.read_fluorescence(\n excitation_wavelength=485, emission_wavelength=528, focal_height=7.5\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "oxn123gjoh", + "source": "# Luminescence\ndata = await c5.luminescence.read_luminescence(focal_height=4.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "1qn3t2pqvw", + "source": "## Microscopy\n\nBoth the Cytation 5 and Cytation 1 expose a `.microscopy` capability. For imaging, pass `use_cam=True` during setup so the Spinnaker camera is initialized. For the full API, see [Microscopy](../../../capabilities/microscopy).\n\nUse {class}`~pylabrobot.agilent.biotek.cytation.CytationBackend.CaptureParams` to control LED intensity, coverage tiling, and pixel format.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "qr1jm6691a", + "source": "from pylabrobot.agilent.biotek.cytation import CytationBackend, CytationImagingConfig\nfrom pylabrobot.capabilities.microscopy.standard import ImagingMode, Objective\n\nres = await c5.microscopy.capture(\n row=1,\n column=2,\n mode=ImagingMode.BRIGHTFIELD,\n objective=Objective.O_4X_PL_FL_Phase,\n focal_height=0.833,\n exposure_time=5,\n gain=16,\n plate=plate,\n backend_params=CytationBackend.CaptureParams(led_intensity=10),\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "km95iou30f", + "source": "Tile multiple fields of view with the `coverage` parameter:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "fi1o94l1uni", + "source": "res = await c5.microscopy.capture(\n row=1,\n column=2,\n mode=ImagingMode.BRIGHTFIELD,\n objective=Objective.O_4X_PL_FL_Phase,\n focal_height=0.833,\n exposure_time=5,\n gain=16,\n plate=plate,\n backend_params=CytationBackend.CaptureParams(\n led_intensity=10,\n coverage=(4, 4),\n ),\n)\nprint(f\"{len(res.images)} images captured\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "sa9pdeeo51", + "source": "## Temperature control\n\nBoth models expose a `.temperature` controller. For the full API, see [Temperature Control](../../../capabilities/temperature-control).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "qhsjnerhl3", + "source": "await c5.temperature.set_temperature(37.0)\n\ncurrent = await c5.temperature.request_temperature()\nprint(f\"{current:.1f} \\u00b0C\")\n\nawait c5.temperature.deactivate()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "f667qnt4occ", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "xcqz2zwu04g", + "source": "await c5.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/agilent/biotek/index.md b/docs/user_guide/agilent/biotek/index.md index df56e26c37f..f3a09e263c7 100644 --- a/docs/user_guide/agilent/biotek/index.md +++ b/docs/user_guide/agilent/biotek/index.md @@ -4,4 +4,6 @@ :maxdepth: 1 el406/hello-world +cytation/hello-world +synergy_h1/hello-world ``` diff --git a/docs/user_guide/agilent/biotek/synergy_h1/hello-world.ipynb b/docs/user_guide/agilent/biotek/synergy_h1/hello-world.ipynb new file mode 100644 index 00000000000..d7c5fde5aba --- /dev/null +++ b/docs/user_guide/agilent/biotek/synergy_h1/hello-world.ipynb @@ -0,0 +1,179 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "9hb8l612ogw", + "source": "# Agilent BioTek Synergy H1\n\nThe Synergy H1 is a multimode microplate reader from Agilent BioTek. It supports:\n\n- [Absorbance](../../../capabilities/absorbance) (230--999 nm)\n- [Fluorescence](../../../capabilities/fluorescence) (excitation 250--700 nm, emission 250--700 nm)\n- [Luminescence](../../../capabilities/luminescence)\n- [Temperature control](../../../capabilities/temperature-control) (heating only, up to 45 °C)\n\nIt communicates over USB via an FTDI serial interface.\n\n```bash\npip install pylabrobot[ftdi]\n```\n\nSee the [Synergy H1 user manual](https://cqls.oregonstate.edu/sites/cqls.oregonstate.edu/files/synergy_h1_user_manual_sd-xb000426.pdf) for hardware setup.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "df745ilqnca", + "source": "## Setup", + "metadata": {} + }, + { + "cell_type": "code", + "id": "vztlq0m8g5", + "source": "from pylabrobot.agilent.biotek import SynergyH1\n\nreader = SynergyH1(name=\"reader\")\nawait reader.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "qpm6e4d70h", + "source": "If you have multiple FTDI devices connected, pass a `device_id` to select the correct one:\n\n```python\nreader = SynergyH1(name=\"reader\", device_id=\"12345678\")\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "oiqom4e4bda", + "source": "Open the tray to load a plate, then close it. Assign a plate resource so the reader knows the well layout.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "6c6ji331z5e", + "source": "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\nawait reader.open()\nplate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\nreader.plate_holder.assign_child_resource(plate)\nawait reader.close()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ac212h02m47", + "source": "## Absorbance\n\nThe Synergy H1 supports absorbance readings from 230 to 999 nm. For the full API, see [Absorbance](../../../capabilities/absorbance).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "p6bs81vwg5j", + "source": "results = await reader.absorbance.read(plate, wavelength=434)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "qkpmhl42ovn", + "source": "## Fluorescence\n\nExcitation range: 250--700 nm. Emission range: 250--700 nm. Focal height range: 4.5--10.68 mm. For the full API, see [Fluorescence](../../../capabilities/fluorescence).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "0qsn8g9uki2i", + "source": "results = await reader.fluorescence.read(\n plate, excitation_wavelength=485, emission_wavelength=528, focal_height=7.5\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "h55fu4bcvyl", + "source": "## Luminescence\n\nFocal height range: 4.5--10.68 mm. For the full API, see [Luminescence](../../../capabilities/luminescence).\n\nUse {class}`~pylabrobot.agilent.biotek.biotek.BioTekBackend.LuminescenceParams` to set the integration time (default 1 s).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "fzj3e9tf9ui", + "source": "results = await reader.luminescence.read(plate, focal_height=4.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "643yoi3vyz8", + "source": "With a custom integration time:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "eened8jpbh", + "source": "from pylabrobot.agilent.biotek import BioTekBackend\n\nresults = await reader.luminescence.read(\n plate,\n focal_height=4.5,\n backend_params=BioTekBackend.LuminescenceParams(integration_time=2),\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "vwzselcn4bn", + "source": "## Shaking\n\nThe Synergy H1 supports linear and orbital shaking via the driver. Frequency is specified in mm (1--6 mm, where lower values correspond to higher CPM).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "33sl477jl52", + "source": "await reader.driver.shake(\n shake_type=BioTekBackend.ShakeType.LINEAR,\n frequency=4, # linear frequency in mm, 1 <= frequency <= 6\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "h3aajxmrwhi", + "source": "await reader.driver.stop_shaking()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "efp70zyvdrs", + "source": "## Temperature control\n\nThe Synergy H1 supports heating up to 45 °C but does not support active cooling. For the full API, see [Temperature Control](../../../capabilities/temperature-control).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "g2ku2uh2zud", + "source": "await reader.temperature.set_temperature(37.0)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "l9hi5407ljb", + "source": "current = await reader.temperature.request_temperature()\nprint(f\"{current:.1f} °C\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "0p22gay4kx5", + "source": "await reader.temperature.deactivate()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "ypd7hm8dc8g", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "1slsxpowz65", + "source": "await reader.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/bmg_labtech/clariostar/hello-world.ipynb b/docs/user_guide/bmg_labtech/clariostar/hello-world.ipynb new file mode 100644 index 00000000000..163fa91e248 --- /dev/null +++ b/docs/user_guide/bmg_labtech/clariostar/hello-world.ipynb @@ -0,0 +1,204 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# BMG Labtech CLARIOstar\n", + "\n", + "The CLARIOstar is a multi-mode plate reader from BMG Labtech. It supports:\n", + "\n", + "- [Absorbance](../../../capabilities/absorbance) (OD or transmittance)\n", + "- [Luminescence](../../../capabilities/luminescence)\n", + "- [Fluorescence](../../../capabilities/fluorescence) (not yet implemented in PLR)\n", + "\n", + "Additional hardware features include a motorized loading tray, temperature control, and shaking.\n", + "\n", + "| Model | PLR Name |\n", + "|---|---|\n", + "| CLARIOstar / CLARIOstar Plus | `CLARIOstar` |\n", + "\n", + "- [OEM Link](https://www.bmglabtech.com/en/clariostar-plus/)\n", + "- **Communication**: USB (FTDI, VID 0x0403 / PID 0xBB68) at 125 000 baud\n", + "- **Communication level**: Firmware" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "The CLARIOstar requires two cable connections:\n", + "\n", + "1. Power cord (standard IEC C13)\n", + "2. USB cable (USB-B with security screws at the CLARIOstar end; USB-A at the control PC end)\n", + "\n", + "If you have a plate stacking unit, an additional RS-232 port is available on the CLARIOstar." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.bmg_labtech import CLARIOstar\n", + "\n", + "reader = CLARIOstar(name=\"clariostar\")\n", + "await reader.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "```{note}\n", + "On `setup()` the device performs its initialization routine. Pass a `device_id` to `CLARIOstar()` if you have multiple FTDI devices connected.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Loading tray\n", + "\n", + "Open and close the motorized plate tray:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.open()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# load a plate (manually or via robotic arm)\n", + "await reader.close()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Absorbance\n", + "\n", + "The absorbance capability is exposed as `reader.absorbance`. For the full API, see [Absorbance](../../../capabilities/absorbance).\n", + "\n", + "Use {class}`~pylabrobot.bmg_labtech.clariostar.absorbance_backend.CLARIOstarAbsorbanceParams` to select between OD and transmittance output." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", + "reader.plate_holder.assign_child_resource(plate)\n", + "\n", + "results = await reader.absorbance.read_absorbance(plate, wavelength=450)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To get raw transmittance values instead of OD:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.bmg_labtech import CLARIOstarAbsorbanceParams\n", + "\n", + "results = await reader.absorbance.read_absorbance(\n", + " plate,\n", + " wavelength=450,\n", + " backend_params=CLARIOstarAbsorbanceParams(report=\"transmittance\"),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Luminescence\n", + "\n", + "The luminescence capability is exposed as `reader.luminescence`. For the full API, see [Luminescence](../../../capabilities/luminescence)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.luminescence.read_luminescence(plate, focal_height=13.0)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Fluorescence\n", + "\n", + "The fluorescence capability is exposed as `reader.fluorescence`. For the full API, see [Fluorescence](../../../capabilities/fluorescence).\n", + "\n", + "```{note}\n", + "Fluorescence reading is not yet implemented in the CLARIOstar driver.\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/bmg_labtech/index.md b/docs/user_guide/bmg_labtech/index.md new file mode 100644 index 00000000000..c7de9147e4b --- /dev/null +++ b/docs/user_guide/bmg_labtech/index.md @@ -0,0 +1,7 @@ +# BMG Labtech + +```{toctree} +:maxdepth: 1 + +clariostar/hello-world +``` diff --git a/docs/user_guide/byonoy/absorbance_96/hello-world.ipynb b/docs/user_guide/byonoy/absorbance_96/hello-world.ipynb new file mode 100644 index 00000000000..b1255e4f78b --- /dev/null +++ b/docs/user_guide/byonoy/absorbance_96/hello-world.ipynb @@ -0,0 +1,105 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "n0a5pl74u0o", + "source": "# Byonoy Absorbance 96\n\nThe Absorbance 96 Automate (A96A) is a USB-HID plate reader from Byonoy that measures absorbance across a 96-well plate in a single flash. It supports:\n\n- [Absorbance](../../capabilities/absorbance) (single-wavelength, full-plate)\n\nThe hardware consists of three physical parts: a **base unit** (holds the plate), an **illumination unit** (light source, sits on top during measurement), and an optional **SBS adapter** for standard footprint integration. PLR models all three as resources so a robotic arm can move the illumination unit on and off the base.\n\n| Model | PLR Name | Factory function |\n|---|---|---|\n| Absorbance 96 Automate (full setup) | `ByonoyAbsorbance96` | `byonoy_a96a` |\n| Detection unit only | `ByonoyAbsorbance96` | `byonoy_a96a_detection_unit` |\n| Illumination unit | `Resource` | `byonoy_a96a_illumination_unit` |\n| Parking base (no backend) | `ByonoyAbsorbanceBaseUnit` | `byonoy_a96a_parking_unit` |\n| SBS adapter | `ResourceHolder` | `byonoy_sbs_adapter` |\n\n- **Communication**: USB HID (VID `0x16D0` / PID `0x1199`)\n- **Communication level**: Firmware", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "tupar6h068", + "source": "## Setup\n\nUse `byonoy_a96a` to create the full setup (detection unit + illumination unit). The detection unit is both a `Resource` (base with plate holder) and a `Device` (drives the backend over USB HID).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "w1m3gjaser", + "source": "from pylabrobot.byonoy import byonoy_a96a\n\nreader, illumination_unit = byonoy_a96a(name=\"a96a\")\nawait reader.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "tby71rvde8s", + "source": "During `setup()`, the backend opens the USB HID connection and runs an initialization measurement to calibrate the sensor.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "j2dquxyo5gm", + "source": "## Absorbance\n\nThe absorbance capability is exposed as `reader.absorbance`. For the full API, see [Absorbance](../../capabilities/absorbance).\n\nBefore reading, assign a plate to the base unit's plate holder and make sure the illumination unit is removed from the base (the interlock will raise an error otherwise).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "dugdvssa3zq", + "source": "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\n# Remove the illumination unit from the base so the plate can be loaded\nreader.illumination_unit_holder.unassign_child_resource(illumination_unit)\n\nplate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\nreader.plate_holder.assign_child_resource(plate)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "re14dqp1uxb", + "source": "Read absorbance at a single wavelength. The wavelength must be one of the device's available wavelengths (queried automatically during `setup()`).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "bhi338chcfk", + "source": "results = await reader.absorbance.read_absorbance(plate, wavelength=450)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "qrtoibefkpl", + "source": "You can check which wavelengths are available on your unit:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "6p6cl01jswv", + "source": "print(reader.driver.available_wavelengths)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "43bn89mateq", + "source": "## Resource layout\n\nThe Absorbance 96 has an interlock: you cannot assign a plate to the base while the illumination unit is on top. In an automated workcell, use a robotic arm to move the illumination unit to a parking base before loading the plate.\n\n```\nByonoyAbsorbance96 (base + device)\n +-- plate_holder (assign plates here)\n +-- illumination_unit_holder (illumination unit sits here during measurement)\n```\n\nA separate `byonoy_a96a_parking_unit` can be used as a second base (no backend) where the illumination unit rests while plates are swapped.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "vt3457fkbz", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "2bhu26jg5gt", + "source": "await reader.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/byonoy/index.md b/docs/user_guide/byonoy/index.md new file mode 100644 index 00000000000..3cfedb983f5 --- /dev/null +++ b/docs/user_guide/byonoy/index.md @@ -0,0 +1,8 @@ +# Byonoy + +```{toctree} +:maxdepth: 1 + +absorbance_96/hello-world +luminescence_96/hello-world +``` diff --git a/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb new file mode 100644 index 00000000000..d225b5e7c5d --- /dev/null +++ b/docs/user_guide/byonoy/luminescence_96/hello-world.ipynb @@ -0,0 +1,93 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "ey04kywsg19", + "source": "# Byonoy Luminescence 96\n\nThe Luminescence 96 is a USB-HID plate reader from Byonoy that measures luminescence across a 96-well plate. It supports:\n\n- [Luminescence](../../capabilities/luminescence) (full-plate, configurable integration time)\n\nThe hardware consists of a **base unit** (holds the plate) and a **reader unit** (detector, sits on top during measurement). PLR models both as resources so a robotic arm can move the reader unit on and off the base. Two hardware variants exist: the L96 (manual) and L96A (automate, with a preferred pickup location for robotic handling).\n\n| Model | PLR Name | Factory function |\n|---|---|---|\n| L96 full setup | `ByonoyLuminescenceBaseUnit` + `ByonoyLuminescence96` | `byonoy_l96` |\n| L96A full setup (automate) | `ByonoyLuminescenceBaseUnit` + `ByonoyLuminescence96` | `byonoy_l96a` |\n| L96 reader unit only | `ByonoyLuminescence96` | `byonoy_l96_reader_unit` |\n| L96A reader unit only | `ByonoyLuminescence96` | `byonoy_l96a_reader_unit` |\n| L96 base unit only | `ByonoyLuminescenceBaseUnit` | `byonoy_l96_base_unit` |\n| L96A base unit only | `ByonoyLuminescenceBaseUnit` | `byonoy_l96a_base_unit` |\n\n- **Communication**: USB HID (VID `0x16D0` / PID `0x119B`)\n- **Communication level**: Firmware", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "9y33vci34d6", + "source": "## Setup\n\nUse `byonoy_l96a` (automate) or `byonoy_l96` (manual) to create the full setup (base unit + reader unit). The reader unit is both a `Resource` and a `Device`.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "g7yhpou4ecd", + "source": "from pylabrobot.byonoy import byonoy_l96a\n\nbase, reader = byonoy_l96a(name=\"l96a\")\nawait reader.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "yaf5kv2g5np", + "source": "## Luminescence\n\nThe luminescence capability is exposed as `reader.luminescence`. For the full API, see [Luminescence](../../capabilities/luminescence).\n\nBefore reading, remove the reader unit from the base so a plate can be assigned, then place the reader unit back.", + "metadata": {} + }, + { + "cell_type": "code", + "id": "ochf7cbgdxi", + "source": "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n\n# Remove reader unit so plate can be loaded\nbase.reader_unit_holder.unassign_child_resource(reader)\n\nplate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\nbase.plate_holder.assign_child_resource(plate)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "h7wn22dkjls", + "source": "results = await reader.luminescence.read_luminescence(plate, focal_height=13.0)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "3zuj9ae45as", + "source": "### Custom integration time\n\nUse {class}`~pylabrobot.byonoy.luminescence_96.ByonoyLuminescence96Backend.LuminescenceParams` to set the integration time (in seconds, default 2).", + "metadata": {} + }, + { + "cell_type": "code", + "id": "49joyxhhy8t", + "source": "from pylabrobot.byonoy import ByonoyLuminescence96Backend\n\nresults = await reader.luminescence.read_luminescence(\n plate,\n focal_height=13.0,\n backend_params=ByonoyLuminescence96Backend.LuminescenceParams(integration_time=5),\n)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "tjfltsit53", + "source": "## Resource layout\n\nThe Luminescence 96 has an interlock: you cannot assign a plate to the base while the reader unit is on top. In an automated workcell, use a robotic arm to move the reader unit off the base before loading the plate.\n\n```\nByonoyLuminescenceBaseUnit (base)\n +-- plate_holder (assign plates here)\n +-- reader_unit_holder (reader unit sits here during measurement)\n```", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "ck97t28eylh", + "source": "## Teardown", + "metadata": {} + }, + { + "cell_type": "code", + "id": "thvjaquzdj", + "source": "await reader.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index 6d3ca41117d..52d3ed5307f 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -32,9 +32,12 @@ definitions agilent/index azenta/index +bmg_labtech/index +byonoy/index inheco/index liconic/index mettler_toledo/index +molecular_devices/index qinstruments/index thermo_fisher/index ``` diff --git a/docs/user_guide/molecular_devices/imageXpress/pico.ipynb b/docs/user_guide/molecular_devices/imageXpress/pico.ipynb new file mode 100644 index 00000000000..3cbc6b56b48 --- /dev/null +++ b/docs/user_guide/molecular_devices/imageXpress/pico.ipynb @@ -0,0 +1,227 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ImageXpress Pico\n", + "\n", + "The Molecular Devices ImageXpress Pico is an automated microscope that communicates over gRPC/SiLA 2. It supports brightfield and fluorescence imaging with multiple objectives and filter cubes.\n", + "\n", + "| Model | PLR Name | Capabilities |\n", + "|---|---|---|\n", + "| ImageXpress Pico | `Pico` | Microscopy (brightfield, DAPI, GFP, RFP, Texas Red, Cy5) |\n", + "\n", + "**Requirements:** `grpcio`, `numpy`, and optionally `Pillow` for TIFF decoding. Install with `pip install grpcio numpy Pillow`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup\n", + "\n", + "Create the `Pico` device with the instrument's network address. Use the `objectives` and `filter_cubes` arguments to declare which optics are installed at each turret/wheel position -- the driver will configure the instrument on setup." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.molecular_devices.imageXpress.pico import Pico\n", + "from pylabrobot.capabilities.microscopy import ImagingMode, Objective\n", + "\n", + "pico = Pico(\n", + " name=\"pico\",\n", + " host=\"192.168.1.100\", # replace with your instrument's IP\n", + " objectives={\n", + " 0: Objective.O_4X_PL_FL,\n", + " 1: Objective.O_10X_PL_FL,\n", + " 2: Objective.O_20X_PL_FL,\n", + " },\n", + " filter_cubes={\n", + " 0: ImagingMode.BRIGHTFIELD,\n", + " 1: ImagingMode.DAPI,\n", + " 2: ImagingMode.GFP,\n", + " },\n", + ")\n", + "await pico.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Plate setup\n", + "\n", + "The Pico derives labware geometry (well spacing, bottom thickness, etc.) from the PLR plate definition, so any plate resource works:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Imaging\n", + "\n", + "The Pico exposes a {class}`~pylabrobot.capabilities.microscopy.microscopy.Microscopy` capability on `pico.microscopy`. For the full API including auto-exposure and auto-focus, see [Microscopy](../../capabilities/microscopy)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Brightfield capture" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "result = await pico.microscopy.capture(\n", + " well=plate.get_well(\"A1\"),\n", + " mode=ImagingMode.BRIGHTFIELD,\n", + " objective=Objective.O_10X_PL_FL,\n", + " plate=plate,\n", + " exposure_time=50.0, # ms\n", + " focal_height=5.0, # mm\n", + " gain=1.0,\n", + ")\n", + "\n", + "print(f\"Captured {len(result.images)} image(s)\")\n", + "print(f\"Exposure: {result.exposure_time} ms, focal height: {result.focal_height} mm\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Fluorescence capture" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "result = await pico.microscopy.capture(\n", + " well=plate.get_well(\"B3\"),\n", + " mode=ImagingMode.DAPI,\n", + " objective=Objective.O_20X_PL_FL,\n", + " plate=plate,\n", + " exposure_time=100.0,\n", + " focal_height=5.0,\n", + " gain=1.0,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Supported objectives and imaging modes\n", + "\n", + "| Objective | PLR Enum |\n", + "|---|---|\n", + "| N PLAN 2.5x/0.07 | `Objective.O_2_5X_N_PLAN` |\n", + "| PL FLUOTAR 4x/0.13 | `Objective.O_4X_PL_FL` |\n", + "| PL FLUOTAR 10x/0.30 | `Objective.O_10X_PL_FL` |\n", + "| PL FLUOTAR 20x/0.40 | `Objective.O_20X_PL_FL` |\n", + "| PL FLUOTAR 40x/0.60 | `Objective.O_40X_PL_FL` |\n", + "\n", + "| Imaging Mode | PLR Enum |\n", + "|---|---|\n", + "| Brightfield | `ImagingMode.BRIGHTFIELD` |\n", + "| DAPI | `ImagingMode.DAPI` |\n", + "| GFP | `ImagingMode.GFP` |\n", + "| RFP | `ImagingMode.RFP` |\n", + "| Texas Red | `ImagingMode.TEXAS_RED` |\n", + "| Cy5 | `ImagingMode.CY5` |" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Door control\n", + "\n", + "Open and close the plate drawer via the driver:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await pico.driver.open_door()\n", + "# load plate\n", + "await pico.driver.close_door()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Objective maintenance\n", + "\n", + "To physically swap an objective, enter maintenance mode for the turret position:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await pico.microscopy.backend.enter_objective_maintenance(position=0)\n", + "# swap the objective\n", + "await pico.microscopy.backend.exit_objective_maintenance()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await pico.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/molecular_devices/index.md b/docs/user_guide/molecular_devices/index.md new file mode 100644 index 00000000000..6cc6140cc38 --- /dev/null +++ b/docs/user_guide/molecular_devices/index.md @@ -0,0 +1,8 @@ +# Molecular Devices + +```{toctree} +:maxdepth: 1 + +spectramax/hello-world +imageXpress/pico +``` diff --git a/docs/user_guide/molecular_devices/spectramax/hello-world.ipynb b/docs/user_guide/molecular_devices/spectramax/hello-world.ipynb new file mode 100644 index 00000000000..f91146d9cfd --- /dev/null +++ b/docs/user_guide/molecular_devices/spectramax/hello-world.ipynb @@ -0,0 +1,278 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Molecular Devices SpectraMax\n", + "\n", + "The SpectraMax family of plate readers from Molecular Devices communicate over RS-232 serial. Both models share the same serial driver ({class}`~pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesDriver`), but differ in capabilities.\n", + "\n", + "| Model | PLR Name | Absorbance | Fluorescence | Luminescence | Temperature Control |\n", + "|---|---|---|---|---|---|\n", + "| SpectraMax M5 | `SpectraMaxM5` | yes | yes | yes | yes |\n", + "| SpectraMax 384 Plus | `SpectraMax384Plus` | yes | no | no | yes |\n", + "\n", + "Connect the reader to your computer via an RS-232 serial cable (or USB-to-serial adapter). Note the serial port name (e.g. `COM3` on Windows, `/dev/ttyUSB0` on Linux)." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.molecular_devices.spectramax import SpectraMaxM5\n", + "\n", + "reader = SpectraMaxM5(name=\"spectramax\", port=\"/dev/ttyUSB0\") # replace with your port\n", + "await reader.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For the SpectraMax 384 Plus:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# from pylabrobot.molecular_devices.spectramax import SpectraMax384Plus\n", + "#\n", + "# reader = SpectraMax384Plus(name=\"spectramax\", port=\"/dev/ttyUSB0\")\n", + "# await reader.setup()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Assign a plate to the reader's built-in plate holder:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", + "\n", + "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", + "reader.plate_holder.assign_child_resource(plate)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Absorbance\n", + "\n", + "Both the M5 and 384 Plus support absorbance reads. For the full API, see [Absorbance](../../capabilities/absorbance).\n", + "\n", + "Use {class}`~pylabrobot.molecular_devices.spectramax.backend.MolecularDevicesAbsorbanceBackend.AbsorbanceParams` to configure backend-specific settings like speed reads, path check, and kinetic reads." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.absorbance.read_absorbance(plate, wavelength=450)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With backend params:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": "from pylabrobot.molecular_devices.spectramax.backend import (\n Calibrate,\n MolecularDevicesAbsorbanceBackend,\n)\n\nresults = await reader.absorbance.read_absorbance(\n plate,\n wavelength=450,\n backend_params=MolecularDevicesAbsorbanceBackend.AbsorbanceParams(\n speed_read=True,\n calibrate=Calibrate.ONCE,\n ),\n)" + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Fluorescence (M5 only)\n", + "\n", + "The SpectraMax M5 supports fluorescence reads. For the full API, see [Fluorescence](../../capabilities/fluorescence).\n", + "\n", + "Use {class}`~pylabrobot.molecular_devices.spectramax.spectramax_m5.SpectraMaxM5FluorescenceBackend.FluorescenceParams` to configure excitation/emission wavelengths, cutoff filters, PMT gain, and other settings." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "results = await reader.fluorescence.read_fluorescence(\n", + " plate,\n", + " excitation_wavelength=485,\n", + " emission_wavelength=520,\n", + " focal_height=0.0,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With backend params for PMT gain and bottom-read:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.molecular_devices.spectramax.spectramax_m5 import SpectraMaxM5FluorescenceBackend\n", + "from pylabrobot.molecular_devices.spectramax.backend import PmtGain\n", + "\n", + "results = await reader.fluorescence.read_fluorescence(\n", + " plate,\n", + " excitation_wavelength=485,\n", + " emission_wavelength=520,\n", + " focal_height=0.0,\n", + " backend_params=SpectraMaxM5FluorescenceBackend.FluorescenceParams(\n", + " pmt_gain=PmtGain.HIGH,\n", + " read_from_bottom=True,\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Luminescence (M5 only)\n", + "\n", + "The SpectraMax M5 supports luminescence reads. For the full API, see [Luminescence](../../capabilities/luminescence).\n", + "\n", + "Use {class}`~pylabrobot.molecular_devices.spectramax.spectramax_m5.SpectraMaxM5LuminescenceBackend.LuminescenceParams` to configure emission wavelengths, PMT gain, and other settings. Note that `emission_wavelengths` is required for luminescence reads on the M5." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.molecular_devices.spectramax.spectramax_m5 import SpectraMaxM5LuminescenceBackend\n", + "\n", + "results = await reader.luminescence.read_luminescence(\n", + " plate,\n", + " focal_height=0.0,\n", + " backend_params=SpectraMaxM5LuminescenceBackend.LuminescenceParams(\n", + " emission_wavelengths=[460],\n", + " ),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Temperature control\n", + "\n", + "Both models expose a {class}`~pylabrobot.capabilities.temperature_controlling.temperature_controller.TemperatureController` on `reader.tc`. Temperature range is 0--45 C. For the full API, see [Temperature Control](../../capabilities/temperature-control)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.tc.set_temperature(37.0)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "current = await reader.tc.request_temperature()\n", + "print(f\"{current:.1f} \\u00b0C\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.tc.deactivate()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Tray control\n", + "\n", + "Open and close the plate tray via the driver:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.driver.open()\n", + "# load plate\n", + "await reader.driver.close()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Teardown" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "await reader.stop()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/migration-guide-for-claude.md b/migration-guide-for-claude.md index 56702c50d84..48d83ba3eaa 100644 --- a/migration-guide-for-claude.md +++ b/migration-guide-for-claude.md @@ -123,3 +123,4 @@ Run `make clean-docs && make docs` for a full build including API docs. Fix any | Inheco Incubator Shaker | `01_material-handling/storage/inheco/incubator_shaker.ipynb` | `inheco/incubator_shaker/hello-world.ipynb` | | Inheco ODTC | `01_material-handling/thermocycling/inheco-odtc.ipynb` | `inheco/odtc/hello-world.ipynb` | | Thermo Fisher Multidrop Combi | _(new with codebase)_ | `thermo_fisher/multidrop_combi/hello-world.ipynb` | +| BioTek Cytation | `02_analytical/plate-reading/cytation.ipynb` | `agilent/biotek/cytation/hello-world.ipynb` | From d307355c185e22cfa0fb1d23f43f05bfa9159ef4 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Tue, 31 Mar 2026 23:27:01 -0700 Subject: [PATCH 32/69] Migrate all material-handling devices to manufacturer-based docs Move remaining 01_material-handling/ device docs to manufacturer layout: Brooks PreciseFlex, Agilent VSpin, Hamilton HEPA Fan, Hamilton Heater Shaker, Hamilton Heater Cooler, TFS Cytomat, Opentrons Temperature Module. Create pylabrobot/hamilton/heater_cooler/ module (stubbed) with Device/Driver/CapabilityBackend architecture. Add API reference RST files for brooks, hamilton, opentrons. Resolve merge with remote analytical device migrations (bmg_labtech, byonoy, molecular_devices). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/api/pylabrobot.brooks.rst | 31 + docs/api/pylabrobot.hamilton.rst | 52 ++ docs/api/pylabrobot.opentrons.rst | 20 + docs/api/pylabrobot.rst | 3 + .../_material-handling.rst | 4 - .../01_material-handling/arms/_arm.rst | 12 - .../arms/c_scara/_scara.rst | 12 - .../precise-flex-pf400/hello-world.ipynb | 394 ------------- .../centrifuge/_centrifuge.md | 24 - .../centrifuge/agilent_vspin.ipynb | 352 ----------- .../01_material-handling/fans/fans.md | 23 - .../fans/hamilton_hepa_scap.ipynb | 121 ---- .../fans/img/hamilton-old-hepa-cap.png | Bin 409760 -> 0 bytes .../fans/img/star-scap.png | Bin 184442 -> 0 bytes .../fans/img/starlet_old_hepa_fan.jpeg | Bin 28076 -> 0 bytes .../heating_shaking/hamilton.ipynb | 546 ------------------ .../heating_shaking/heating_shaking.md | 27 - .../storage/cytomat.ipynb | 183 ------ .../01_material-handling/storage/storage.rst | 6 - .../hamilton-heater-cooler.ipynb | 135 ----- .../ot-temperature-controller.ipynb | 381 ------------ .../temperature-controllers.rst | 3 - docs/user_guide/agilent/index.md | 1 + .../agilent/vspin/hello-world.ipynb | 185 ++++++ docs/user_guide/brooks/index.md | 7 + .../brooks/precise_flex/hello-world.ipynb | 183 ++++++ .../hamilton/heater_cooler/hello-world.ipynb | 111 ++++ .../hamilton/heater_shaker/hello-world.ipynb | 191 ++++++ .../hamilton/hepa_fan/hello-world.ipynb | 117 ++++ docs/user_guide/hamilton/index.md | 9 + docs/user_guide/index.md | 3 + docs/user_guide/opentrons/index.md | 7 + .../temperature_module/hello-world.ipynb | 117 ++++ .../thermo_fisher/cytomat/hello-world.ipynb | 155 +++++ docs/user_guide/thermo_fisher/index.md | 1 + migration-guide-for-claude.md | 1 + pylabrobot/hamilton/heater_cooler/__init__.py | 5 + pylabrobot/hamilton/heater_cooler/backend.py | 44 ++ .../hamilton/heater_cooler/heater_cooler.py | 53 ++ 39 files changed, 1296 insertions(+), 2223 deletions(-) create mode 100644 docs/api/pylabrobot.brooks.rst create mode 100644 docs/api/pylabrobot.hamilton.rst create mode 100644 docs/api/pylabrobot.opentrons.rst delete mode 100644 docs/user_guide/01_material-handling/arms/_arm.rst delete mode 100644 docs/user_guide/01_material-handling/arms/c_scara/_scara.rst delete mode 100644 docs/user_guide/01_material-handling/arms/c_scara/precise-flex-pf400/hello-world.ipynb delete mode 100644 docs/user_guide/01_material-handling/centrifuge/_centrifuge.md delete mode 100644 docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb delete mode 100644 docs/user_guide/01_material-handling/fans/fans.md delete mode 100644 docs/user_guide/01_material-handling/fans/hamilton_hepa_scap.ipynb delete mode 100644 docs/user_guide/01_material-handling/fans/img/hamilton-old-hepa-cap.png delete mode 100644 docs/user_guide/01_material-handling/fans/img/star-scap.png delete mode 100644 docs/user_guide/01_material-handling/fans/img/starlet_old_hepa_fan.jpeg delete mode 100644 docs/user_guide/01_material-handling/heating_shaking/hamilton.ipynb delete mode 100644 docs/user_guide/01_material-handling/heating_shaking/heating_shaking.md delete mode 100644 docs/user_guide/01_material-handling/storage/cytomat.ipynb delete mode 100644 docs/user_guide/01_material-handling/temperature-controllers/hamilton-heater-cooler.ipynb delete mode 100644 docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb create mode 100644 docs/user_guide/agilent/vspin/hello-world.ipynb create mode 100644 docs/user_guide/brooks/index.md create mode 100644 docs/user_guide/brooks/precise_flex/hello-world.ipynb create mode 100644 docs/user_guide/hamilton/heater_cooler/hello-world.ipynb create mode 100644 docs/user_guide/hamilton/heater_shaker/hello-world.ipynb create mode 100644 docs/user_guide/hamilton/hepa_fan/hello-world.ipynb create mode 100644 docs/user_guide/hamilton/index.md create mode 100644 docs/user_guide/opentrons/index.md create mode 100644 docs/user_guide/opentrons/temperature_module/hello-world.ipynb create mode 100644 docs/user_guide/thermo_fisher/cytomat/hello-world.ipynb create mode 100644 pylabrobot/hamilton/heater_cooler/__init__.py create mode 100644 pylabrobot/hamilton/heater_cooler/backend.py create mode 100644 pylabrobot/hamilton/heater_cooler/heater_cooler.py diff --git a/docs/api/pylabrobot.brooks.rst b/docs/api/pylabrobot.brooks.rst new file mode 100644 index 00000000000..fed06643ca6 --- /dev/null +++ b/docs/api/pylabrobot.brooks.rst @@ -0,0 +1,31 @@ +.. currentmodule:: pylabrobot.brooks + +pylabrobot.brooks package +========================= + +PreciseFlex +----------- + +.. currentmodule:: pylabrobot.brooks.precise_flex + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PreciseFlex400 + PreciseFlexDriver + PreciseFlexArmBackend + PreciseFlex3400Backend + +.. autoclass:: pylabrobot.brooks.precise_flex.PreciseFlexArmBackend.PickUpParams + :members: + +.. autoclass:: pylabrobot.brooks.precise_flex.PreciseFlexArmBackend.DropParams + :members: + +.. autoclass:: pylabrobot.brooks.precise_flex.PreciseFlexArmBackend.MoveToJointPositionParams + :members: + +.. autoclass:: pylabrobot.brooks.precise_flex.PreciseFlexArmBackend.MoveToLocationParams + :members: diff --git a/docs/api/pylabrobot.hamilton.rst b/docs/api/pylabrobot.hamilton.rst new file mode 100644 index 00000000000..212e6297544 --- /dev/null +++ b/docs/api/pylabrobot.hamilton.rst @@ -0,0 +1,52 @@ +.. currentmodule:: pylabrobot.hamilton + +pylabrobot.hamilton package +=========================== + +Heater Cooler +------------- + +.. currentmodule:: pylabrobot.hamilton.heater_cooler + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + HamiltonHeaterCooler + HamiltonHeaterCoolerDriver + HamiltonHeaterCoolerTemperatureBackend + + +HEPA Fan +-------- + +.. currentmodule:: pylabrobot.hamilton.only_fans + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + HamiltonHepaFan + HamiltonHepaFanDriver + HamiltonHepaFanFanBackend + HamiltonHepaFanChatterboxBackend + + +Heater Shaker +------------- + +.. currentmodule:: pylabrobot.hamilton.heater_shaker + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + HamiltonHeaterShaker + HamiltonHeaterShakerDriver + HamiltonHeaterShakerShakerBackend + HamiltonHeaterShakerTemperatureBackend + HamiltonHeaterShakerBox + HamiltonHeaterShakerInterface diff --git a/docs/api/pylabrobot.opentrons.rst b/docs/api/pylabrobot.opentrons.rst new file mode 100644 index 00000000000..63a24cd2be1 --- /dev/null +++ b/docs/api/pylabrobot.opentrons.rst @@ -0,0 +1,20 @@ +.. currentmodule:: pylabrobot.opentrons + +pylabrobot.opentrons package +============================ + +Temperature Module +------------------ + +.. currentmodule:: pylabrobot.opentrons.temperature_module + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + OpentronsTemperatureModuleV2 + OpentronsTemperatureModuleDriver + OpentronsTemperatureModuleTemperatureBackend + OpentronsTemperatureModuleUSBDriver + OpentronsTemperatureModuleUSBTemperatureBackend diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 9758a4a6a32..72f1969330a 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -35,10 +35,13 @@ Manufacturers pylabrobot.agilent pylabrobot.azenta pylabrobot.bmg_labtech + pylabrobot.brooks pylabrobot.byonoy + pylabrobot.hamilton pylabrobot.inheco pylabrobot.liconic pylabrobot.mettler_toledo pylabrobot.molecular_devices + pylabrobot.opentrons pylabrobot.qinstruments pylabrobot.thermo_fisher diff --git a/docs/user_guide/01_material-handling/_material-handling.rst b/docs/user_guide/01_material-handling/_material-handling.rst index 68091a72dfe..6a25b08344e 100644 --- a/docs/user_guide/01_material-handling/_material-handling.rst +++ b/docs/user_guide/01_material-handling/_material-handling.rst @@ -11,10 +11,6 @@ This includes machines for temperature and motion control (as neither machines p .. toctree:: :maxdepth: 1 - arms/_arm - centrifuge/_centrifuge - heating_shaking/heating_shaking - fans/fans sealers/sealers temperature-controllers/temperature-controllers storage/storage diff --git a/docs/user_guide/01_material-handling/arms/_arm.rst b/docs/user_guide/01_material-handling/arms/_arm.rst deleted file mode 100644 index cf00979e9fc..00000000000 --- a/docs/user_guide/01_material-handling/arms/_arm.rst +++ /dev/null @@ -1,12 +0,0 @@ -Arm -=== - -Robotic arms are a WIP in PLR. - ------------------------------------------- - -.. toctree:: - :maxdepth: 1 - :hidden: - - c_scara/_scara diff --git a/docs/user_guide/01_material-handling/arms/c_scara/_scara.rst b/docs/user_guide/01_material-handling/arms/c_scara/_scara.rst deleted file mode 100644 index 945ff394215..00000000000 --- a/docs/user_guide/01_material-handling/arms/c_scara/_scara.rst +++ /dev/null @@ -1,12 +0,0 @@ -SCARA -===== - -Selective Compliance Assembly Robot Arm (SCARA). - ------------------------------------------- - -.. toctree:: - :maxdepth: 1 - :hidden: - - precise-flex-pf400/hello-world diff --git a/docs/user_guide/01_material-handling/arms/c_scara/precise-flex-pf400/hello-world.ipynb b/docs/user_guide/01_material-handling/arms/c_scara/precise-flex-pf400/hello-world.ipynb deleted file mode 100644 index 1baf5819b13..00000000000 --- a/docs/user_guide/01_material-handling/arms/c_scara/precise-flex-pf400/hello-world.ipynb +++ /dev/null @@ -1,394 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "5f3c5bbc", - "metadata": {}, - "source": [ - "# PreciseFlex PF400 and PF3400 robots\n", - "\n", - "Connection: ethernet" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "594d1a91", - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext autoreload\n", - "%autoreload 2" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ba84cba7", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.arms.scara import ExperimentalSCARA\n", - "from pylabrobot.arms.precise_flex.pf_400 import PreciseFlex400Backend\n", - "\n", - "from pylabrobot.arms.precise_flex.coords import PreciseFlexCartesianCoords\n", - "from pylabrobot.arms.precise_flex.joints import PFAxis\n", - "from pylabrobot.resources import Coordinate, Rotation" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e83c35da", - "metadata": {}, - "outputs": [], - "source": [ - "backend = PreciseFlex400Backend(host=\"192.168.0.1\", port=10100, has_rail=False)\n", - "arm = ExperimentalSCARA(backend=backend)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1a9eb932", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.setup(skip_home=False)" - ] - }, - { - "cell_type": "markdown", - "id": "eb3a5aae", - "metadata": {}, - "source": [ - "## Granular Robot control" - ] - }, - { - "cell_type": "markdown", - "id": "7e0cc273", - "metadata": {}, - "source": [ - "### Gripper Control" - ] - }, - { - "cell_type": "markdown", - "id": "3741f8e2", - "metadata": {}, - "source": [ - "The gripper can be controlled manually as follows:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "451d51c0", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.close_gripper(gripper_width=80)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ec9cd5f2", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.open_gripper(gripper_width=120)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "89517ae8", - "metadata": {}, - "outputs": [], - "source": [ - "await backend.is_gripper_closed()" - ] - }, - { - "cell_type": "markdown", - "id": "6518bc78", - "metadata": {}, - "source": [ - "### Movement" - ] - }, - { - "cell_type": "markdown", - "id": "60b8cede", - "metadata": {}, - "source": [ - "You can also arbitrarily move the arm to cartesian coordinates as well as joint coordinates:" - ] - }, - { - "cell_type": "markdown", - "id": "51ea5969", - "metadata": {}, - "source": [ - "```{warning}\n", - "Depending on the current position, moving to a joint position might actually cause the arm to collide with its base! Be careful when using joint coordinates.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "0ebba776", - "metadata": {}, - "outputs": [], - "source": [ - "location = {\n", - " PFAxis.BASE: 99.981,\n", - " PFAxis.SHOULDER: -36.206,\n", - " PFAxis.ELBOW: 83.063,\n", - " PFAxis.WRIST: -331.7,\n", - " PFAxis.GRIPPER: 126.084,\n", - " PFAxis.RAIL: 0.0,\n", - "}\n", - "await arm.move_to(location)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3e81b5a4", - "metadata": {}, - "outputs": [], - "source": [ - "location = PreciseFlexCartesianCoords(\n", - " location=Coordinate(x=290, y=659, z=100),\n", - " rotation=Rotation(x=-180.0, y=90.0, z=84.804)\n", - ")\n", - "await arm.move_to(location)" - ] - }, - { - "cell_type": "markdown", - "id": "1addb341", - "metadata": {}, - "source": [ - "## Getting current location" - ] - }, - { - "cell_type": "markdown", - "id": "022458da", - "metadata": {}, - "source": [ - "Get the joint angles of the robot's arm, including the rails if applicable and the gripper width:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b515597a", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.get_joint_position()" - ] - }, - { - "cell_type": "markdown", - "id": "f254c14c", - "metadata": {}, - "source": [ - "Get the cartesian coordinates of the robot's end effector:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9d72277a", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.get_cartesian_position()" - ] - }, - { - "cell_type": "markdown", - "id": "6f8c1b6c", - "metadata": {}, - "source": [ - "## Teaching positions using free mode" - ] - }, - { - "cell_type": "markdown", - "id": "e534f0b0", - "metadata": {}, - "source": [ - "Use \"free mode\" to manually move the robot's arm to a desired position, and then read the current cartesian coordinates. You can use the cartesian coordinates to programmatically move the arm to that position later." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "65e03f11", - "metadata": {}, - "outputs": [], - "source": "await arm.freedrive_mode(free_axes=[0])" - }, - { - "cell_type": "code", - "execution_count": null, - "id": "64fd60cf", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.get_cartesian_position()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1b64e546", - "metadata": {}, - "outputs": [], - "source": "await arm.end_freedrive_mode()" - }, - { - "cell_type": "markdown", - "id": "6ef53233", - "metadata": {}, - "source": [ - "## Plate Movement" - ] - }, - { - "cell_type": "markdown", - "id": "af6424a0", - "metadata": {}, - "source": [ - "Below is an example of picking up and placing a plate using cartesian coordinates. You can call `move_to` in between to move to other locations as needed." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "88ae00e3", - "metadata": {}, - "outputs": [], - "source": [ - "location = PreciseFlexCartesianCoords(\n", - " location=Coordinate(x=650.74, y=-345.922, z=5.05),\n", - " rotation=Rotation(x=180.0, y=90.0, z=-9.921)\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18c1a704", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.pick_up_resource(\n", - " location,\n", - " plate_width=125,\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bfeefd6b", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.drop_resource(location)" - ] - }, - { - "cell_type": "markdown", - "id": "62036558", - "metadata": {}, - "source": [ - "## Miscellaneous commands" - ] - }, - { - "cell_type": "markdown", - "id": "80c96216", - "metadata": {}, - "source": [ - "Move the arm to its predefined home/safe position:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8e1e7c7", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.move_to_safe()" - ] - }, - { - "cell_type": "markdown", - "id": "3d07da5b", - "metadata": {}, - "source": [ - "Home the arm:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "78ab2382", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.home()" - ] - }, - { - "cell_type": "markdown", - "id": "076b738d", - "metadata": {}, - "source": [ - "Stop any ongoing movement of the arm:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13b4e525", - "metadata": {}, - "outputs": [], - "source": [ - "await arm.halt()" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.9.24" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} \ No newline at end of file diff --git a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md b/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md deleted file mode 100644 index d6e13a1191b..00000000000 --- a/docs/user_guide/01_material-handling/centrifuge/_centrifuge.md +++ /dev/null @@ -1,24 +0,0 @@ -# Centrifuges - -Centrifuges are controlled by the {class}`~pylabrobot.centrifuge.centrifuge.Centrifuge` class. This class takes a backend as an argument. The backend is responsible for communicating with the centrifuge and is specific to the hardware being used. - -The {class}`~pylabrobot.centrifuge.centrifuge.Centrifuge` class has a number of methods for controlling the centrifuge. These are: - -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.open_door`: Open the centrifuge door. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.close_door`: Close the centrifuge door. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.lock_door`: Lock the centrifuge door. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.unlock_door`: Unlock the centrifuge door. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.lock_bucket`: Lock centrifuge buckets. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.unlock_bucket`: Unlock centrifuge buckets. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket1`: Rotate to Bucket 1. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.go_to_bucket2`: Rotate to Bucket 2. -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.rotate_distance`: Rotate the buckets a specified distance (8000 = 360 degrees). -- {meth}`~pylabrobot.centrifuge.centrifuge.Centrifuge.start_spin_cycle`: Start centrifuge spin cycle. - -PLR supports the following centrifuges: - -```{toctree} -:maxdepth: 1 - -agilent_vspin -``` diff --git a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb b/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb deleted file mode 100644 index e0ab2cc16b8..00000000000 --- a/docs/user_guide/01_material-handling/centrifuge/agilent_vspin.ipynb +++ /dev/null @@ -1,352 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "678edf8f", - "metadata": {}, - "source": [ - "# Agilent VSpin\n", - "\n", - "The VSpin centrifuge is controlled by the {class}`~pylabrobot.centrifuge.vspin_backend.VSpinBackend` class." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e937791a", - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.centrifuge import Centrifuge, VSpinBackend\n", - "vspin_backend = VSpinBackend() # VSpinBackend(device_id=\"YOUR_FTDI_ID_HERE\")\n", - "cf = Centrifuge(name = \"centrifuge\", backend = vspin_backend, size_x= 1, size_y=1, size_z=1)\n", - "await cf.setup()" - ] - }, - { - "cell_type": "markdown", - "id": "e5ee122c", - "metadata": {}, - "source": [ - "Before you can use the \"go to bucket\" commands, you need to calibrate the bucket positions. See [below](#calibrating-bucket-1-position) for instructions." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9f9365f0", - "metadata": {}, - "outputs": [], - "source": [ - "await cf.go_to_bucket1()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c8b872e9", - "metadata": {}, - "outputs": [], - "source": [ - "await cf.go_to_bucket2()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "ed867ca2", - "metadata": {}, - "outputs": [], - "source": [ - "await cf.spin(\n", - " g=800,\n", - " duration=60, # seconds\n", - " acceleration=1.0, # 0-1\n", - " deceleration=1.0, # 0-1\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "a3587acc", - "metadata": {}, - "source": [ - "Status commands" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2d8d3047", - "metadata": {}, - "outputs": [], - "source": [ - "await vspin_backend.get_door_locked()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "57bdc72d", - "metadata": {}, - "outputs": [], - "source": [ - "await vspin_backend.get_door_open()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d3f66052", - "metadata": {}, - "outputs": [], - "source": [ - "await vspin_backend.get_bucket_locked()" - ] - }, - { - "cell_type": "markdown", - "id": "47c4cfc9", - "metadata": {}, - "source": [ - "## Calibrating bucket 1 position\n", - "\n", - "You need to calibrate the bucket 1 position for every vspin once. There are two ways to do this:\n", - "1. Manually: Move bucket 1 to the correct position using the physical controls on the centrifuge.\n", - "2. Programmatically: Use the `go_to_position` command to move bucket 1 to the correct position.\n", - "\n", - "With bucket 1 in the correct position, save it with `cf.backend.set_bucket_1_position_to_current()`. This will save the calibration for the current centrifuge to disk at `~/.pylabrobot/vspin_bucket_calibrations.json` (based on the usb serial number)." - ] - }, - { - "cell_type": "markdown", - "id": "8086eab1", - "metadata": {}, - "source": [ - "### Moving using code\n", - "\n", - "Use `vspin_backend.go_to_position` to move the buckets to the correct position. A full rotation is 8000 ticks, so 4.5 degrees per 100 ticks." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "50fc77a3", - "metadata": {}, - "outputs": [], - "source": [ - "await vspin_backend.go_to_position(100)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7bb22121", - "metadata": {}, - "outputs": [], - "source": [ - "await vspin_backend.set_bucket_1_position_to_current()" - ] - }, - { - "cell_type": "markdown", - "id": "3cbdc48f", - "metadata": {}, - "source": [ - "### Manually rotating\n", - "\n", - "You can open the door unlock the bucket and manually rotate the buckets to the desired position.\n", - "\n", - "```{warning}\n", - "High risk / high reward! The vspin has a safety mechanism that will close the door when it detects movement.\n", - "This means the door will close when you rotate the buckets manually too fast.\n", - "Be careful or it will eat your fingers!\n", - "It will save time compared to using code though.\n", - "```" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "031329fc", - "metadata": {}, - "outputs": [], - "source": [ - "await cf.open_door()\n", - "await vspin_backend.unlock_bucket()" - ] - }, - { - "cell_type": "markdown", - "id": "f0eae259", - "metadata": {}, - "source": [ - "Manually rotate buckets to align bucket 1 with door" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "18a25ed9", - "metadata": {}, - "outputs": [], - "source": [ - "await vspin_backend.set_bucket_1_position_to_current()" - ] - }, - { - "cell_type": "markdown", - "id": "c973648a", - "metadata": {}, - "source": [ - "## Loader\n", - "\n", - "The VSpin can optionally be used with a loader (called Access2). The loader is optional because you can also use a robotic arm like an iSWAP to move a plate directly into the centrifuge.\n", - "\n", - "When using the loader, you must specify the FTDI device ids for both devices because both use FTDI and are otherwise indistinguishable. See [below](#2-finding-the-ftdi-id) for how to find the device ids.\n", - "\n", - "Here's how to use the loader:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f3e17e8f", - "metadata": {}, - "outputs": [], - "source": [ - "import asyncio\n", - "\n", - "from pylabrobot.centrifuge import Access2, VSpinBackend\n", - "vspin_backend = VSpinBackend(device_id=\"YOUR_VSPIN_FTDI_ID_HERE\")\n", - "centrifuge, loader = Access2(name=\"name\", vspin=vspin_backend, device_id=\"YOUR_LOADER_FTDI_ID_HERE\")\n", - "\n", - "# initialize the centrifuge and loader in parallel\n", - "await asyncio.gather(\n", - " centrifuge.setup(),\n", - " loader.setup()\n", - ")\n", - "\n", - "# go to a bucket and open the door before loading\n", - "await centrifuge.go_to_bucket1()\n", - "await centrifuge.open_door()\n", - "\n", - "# assign a plate to the loader before loading. This can also be done implicitly by for example\n", - "# lh.move_plate(plate, loader)\n", - "from pylabrobot.resources import Cor_96_wellplate_360ul_Fb\n", - "plate = Cor_96_wellplate_360ul_Fb(name=\"plate\")\n", - "loader.assign_child_resource(plate)\n", - "\n", - "# load and unload the plate\n", - "await loader.load()\n", - "await loader.unload()" - ] - }, - { - "cell_type": "markdown", - "id": "e3de872f", - "metadata": {}, - "source": [ - "## Installation\n", - "\n", - "The VSpin centrifuge connects to your system via a COM port. Integrating it with `pylabrobot` library requires some setup. Follow this guide to get started.\n", - "\n", - "### 1. Installing libftdi\n", - "\n", - "#### macOS\n", - "\n", - "Install libftdi using [Homebrew](https://brew.sh/):\n", - "\n", - "```bash\n", - "brew install libftdi\n", - "```\n", - "\n", - "#### Linux\n", - "\n", - "Debian (rpi) / Ubuntu etc:\n", - "\n", - "```bash\n", - "sudo apt-get install libftdi-dev\n", - "```\n", - "\n", - "Other distros have similar packages.\n", - "\n", - "#### Windows\n", - "\n", - "**Find Your Python Directory**\n", - "\n", - "To use the necessary FTDI `.dll` files, you need to locate your Python environment:\n", - "\n", - "1. Open Python in your terminal:\n", - " ```python\n", - " python\n", - " >>> import sys\n", - " >>> sys.executable\n", - " ```\n", - "2. This will print a path, e.g., `C:\\Python39\\python.exe`.\n", - "3. Navigate to the `Scripts` folder in the same directory as `python.exe`.\n", - "\n", - "**Download FTDI DLLs**\n", - "\n", - "Download the required `.dll` files from the following link:\n", - "[FTDI Development Kit](https://sourceforge.net/projects/picusb/files/libftdi1-1.5_devkit_x86_x64_19July2020.zip/download) (link will start download).\n", - "\n", - "1. Extract the downloaded zip file.\n", - "2. Locate the `bin64` folder.\n", - "3. Copy the files named:\n", - " - `libftdi1.dll`\n", - " - `libusb-1.0.dll`\n", - "\n", - "**Place DLLs in Python Scripts Folder**\n", - "\n", - "Paste the copied `.dll` files into the `Scripts` folder of your Python environment. This enables Python to communicate with FTDI devices.\n", - "\n", - "**Configuring the Driver with Zadig**\n", - "\n", - "Use Zadig to replace the default driver of the VSpin device with `libusbk`:\n", - "\n", - "1. **Identify the VSpin Device**\n", - "\n", - " - Open Zadig.\n", - " - To confirm the VSpin device, disconnect the RS232 port from the centrifuge while monitoring the Zadig device list.\n", - " - The device that disappears is your VSpin, likely titled \"USB Serial Converter.\"\n", - "\n", - "2. **Replace the Driver**\n", - " - Select the identified VSpin device in Zadig.\n", - " - Replace its driver with `libusbk`.\n", - " - Optionally, rename the device to \"VSpin\" for easy identification.\n", - "\n", - "> **Note:** If you need to revert to the original driver for tools like the Agilent Centrifuge Config Tool, go to **Device Manager** and uninstall the `libusbk` driver. The default driver will reinstall automatically.\n", - "\n", - "### 2. Finding the FTDI ID\n", - "\n", - "To interact with the centrifuge programmatically, you need its FTDI device ID. Use the following steps to find it:\n", - "\n", - "1. Open a terminal and run:\n", - " ```bash\n", - " python -m pylibftdi.examples.list_devices\n", - " ```\n", - "2. This will output something like:\n", - " ```\n", - " FTDI:USB Serial Converter:FTE0RJ5T\n", - " ```\n", - "3. Copy the ID (`FTE0RJ5T` or your equivalent).\n", - "\n", - "You’re now ready to use your VSpin centrifuge with `pylabrobot`!" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "env", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3.9.23" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/docs/user_guide/01_material-handling/fans/fans.md b/docs/user_guide/01_material-handling/fans/fans.md deleted file mode 100644 index 1aa83239005..00000000000 --- a/docs/user_guide/01_material-handling/fans/fans.md +++ /dev/null @@ -1,23 +0,0 @@ -# Fans (Air Filtration Systems) - -In PyLabRobot, fans refer to air filtration units that condition air within or around the deck to protect the sample from contamination. - -Their main purpose is to maintain a clean environment for experiments by ensuring consistent airflow and particle removal, reducing risks from dust, aerosols, and microorganisms. -These systems are not primarily designed for operator safety; separate equipment like fume extractors or biosafety cabinets serves that role. - -Common filter technologies include: - -- **HEPA filters**: Capture ≥99.97% of airborne particles ≥0.3 µm, widely used to keep samples clean. - -- **ULPA filters**: Capture even smaller particles for higher-level cleanroom requirements. - -- **Activated carbon filters**: Remove volatile organic compounds (VOCs) and chemical fumes. - -- **Prefilters**: Trap larger particles to extend the lifespan of HEPA/ULPA filters. - - -```{toctree} -:maxdepth: 1 -:hidden: -hamilton_hepa_scap -``` diff --git a/docs/user_guide/01_material-handling/fans/hamilton_hepa_scap.ipynb b/docs/user_guide/01_material-handling/fans/hamilton_hepa_scap.ipynb deleted file mode 100644 index 6dc8df7b25a..00000000000 --- a/docs/user_guide/01_material-handling/fans/hamilton_hepa_scap.ipynb +++ /dev/null @@ -1,121 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Hamilton HEPA Fan\n", - "\n", - "| Summary | Photo |\n", - "|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|\n", - "| - OEM Link (none exists?)
    - **Communication Protocol / Hardware**: Serial (FTDI)/ USB-A
    - **Communication Level**: Firmware
    - Old HEPA CAP discontinued in 2020, replaced with Clean Air Protection (CAP) Fan (Hamilton cat. no.: 92173-22)
    - Old HEPA CAP VID:PID 0856:ac11 \"USOPTL4\"
    - Adds 321 mm to height of STAR(let)
    - Takes in ambient air, filters it and supplies it to the inside of the STAR(let) | ![quadrants](img/hamilton-old-hepa-cap.png) |" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Physical)\n", - "\n", - "The Hamilton HEPA Fan for a STAR or STARlet liquid handling workstation has to be placed on top of the machines chassis.\n", - "\n", - "It requires two cable connections to be operational:\n", - "1. Power cord (standard IEC C13); if you are using an older HEPA Fan model ensure that the voltage switch is set to your country's mains voltage!\n", - "2. USB cable (USB-B with at fan end; USB-A at control PC end)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "## Setup Instructions (Programmatic)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.only_fans import Fan\n", - "from pylabrobot.only_fans import HamiltonHepaFanBackend" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "fan = Fan(backend=HamiltonHepaFanBackend()) \n", - "# NB.: fans are only machines, they are not modelled as resources -> require no str name\n", - "\n", - "await fan.setup()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "## Usage / Machine Features" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Running for 60 seconds:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await fan.turn_on(intensity=100, duration=30)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Running until stop:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await fan.turn_on(intensity=100)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "await fan.turn_off()" - ] - } - ], - "metadata": { - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 2 -} diff --git a/docs/user_guide/01_material-handling/fans/img/hamilton-old-hepa-cap.png b/docs/user_guide/01_material-handling/fans/img/hamilton-old-hepa-cap.png deleted file mode 100644 index 854c88fb881ff4dcecca17b5998f5abee9643b1b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 409760 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%0A^MHZZD5n_EfvYM#C8oRHzBE9pPakj~`9?f@WKI zge7U>-7Ia)anf2*(XaZ&o~&&y*eXYD_t3}1cnoJlwJi3H$@-y=DYKXs4Ey6xi#Bho`4#HB;nU3 z;46xdnP^gWlp{^GBDT`e-l*IWTFYU8spBVw*i?lk6I?ahS7E`%$w>{Ma&JAvkBfi5 z=7gaqoY9;=Rvb&X5T~Icj&DIIJd&ptw2_M13%I+=Z%2s`S0Q#c-n$Mp@eHAb5dZ4j zl+Io6L(0kU%s$@tENJiDgh#FlOfvHzBF}_$D1W(9Z*SeMvMa9&kH<8%xVvkrQBhO- zUgzDBZr%+^R`hb&pJQZd3N(+crqSN-aK@>OM?0}i2Cp2>owSt7 zvgG+3XKjun=WHKHY9()&yvBv7gS=YL$_~A=|j;}-|CsMb6 z8!98hF0RLl;{4&48A@xndx^U?l)s5AwS=NYJC7=5PfQ?(1C298?9H^^v*Ug=9GMtQ z)ZytDx#vwYgv=7m#fb`7AMjwS*`AyKG~F}_#e!<^qZY{<2XODlQJ!_r%1aHa_(wK? zndR%pYU>51I=igY2Q<3^BlQ9iO$eb$iDSt@uLf4v%HV5z+Xu)9#WUAyR~2SiQTW<`B^@DDC?Q}HbH%)->Y@DxvI6blWD0$9=)zPp3Ss&{(6N(j0V4$@Pd# zHjPwyTUF1@sC{We=L=iM5ae~-8cLE?i5AI)xaE8>!XGA23NmF+F+;xjyb|6cxQT}s zdV+K@Gz7S3%-63WmN6B(mfjkVWpqv50>JJ}Jp1eJCw?D4?aA+FzHdjz1hsYfP8s+)F$`-eABH&E34KQ9L$2KFLe1 zG9VNiWXiO;cXhI@s{S0!BL)@ z^;D^?5e>ZU8u&eoE=J-%8{FZb_1XXN2~B99VT)~=-jc@weI&I>)(oc;1PcS#X{i3@ ziFXW%|EJVN`8Ib$xXuOsqZ^ymqgG$pS(7~Cd-j5Z-Tl9&{<#LUIr{lMeOWHs_seG| zud5Ghil_T#;x0sMxUW&7$3tT z;I8Imp79gadBPJ5Aqr8?p8bt)$RsdUizL$Xj#GUr8F1ww_0Z8wQ z9D&%1@@o(_`*TuQjRzzVr^+PoEbCOxkNvR3N_a|O_K3;O%DAiTdeHwiY+>Og8r+w>Z|0_=*0#U7 zR6WmWVD2b#=Y!`msFH`g7+~~Gp2$k;F}-MHX~GiUIg$Z6bTP?9k$l-%Z<5PpN2$~F zx$u`#%mw76y((W(B%QNi7^~S2hAG~#t3bZeEa^8SksjvWj#)3}ZZ=TQ7_lbs>RXB(g%B&N(zFLpCJn8XZnII%LTTmuQie$(Ai%Pz z$KC?fsA^~WyF8&T<)@5D*q>}r9s@74Yb*b(@r{B7BF-^En!u0Lcj#=Xk(m`-8%i*> zd1+(&&TUVl5uL9sFU=%dwvG_}D6qmgeBy4xD}CI)<{*e24jZT=e69x(#l3l@Mz%A< z`x;m*9A{OgG*};XtbbXSV?8tq0M^sr)`1p^=X^BnL!syx;VWbtSU9w)Y;@oWRbcm z5V;;SYX{jC;koJ$IZiocJFkQ|LmY8?GQ!EYE$R-k>%`G}8N7a9vF|&-PcJ&Fozi(Q zx2A>$i4$aY_*m&n+|rG7w_K}Xf}fXNy9W7W^Q@@J<9nZ;{e&0Dip!8}7-&fnc&bFy@EStLsn zVSV@IEa%y@>6*)BloQ8XHBtflkHpi zJhO|}JI;{{XT=Eyv|G~KGym)HJQqW%-J+sxw{}n0E9a)UZ`N@iUQos)_f(gGvcfhv z8%hL3dKs7fuW$QDw6!r0ca965+vUo(v|LCZp0zC9Z8vbzS`v*l`S;~7p++BX@9*z# zfL2oDPTQSVtYDa&a?#ZFM9r7tnZM4i3&1=OBc+P@*nQd0+2Lw8rK#DzXb&1rGXMB> zxnimnV|U(OdN}*}`FIIY6JF~+eh{dnZD3^QT1+k}r-Tb9-vr=hkOh%&<_hArG)`i< z@JFw1p1NShZx*gmOZ0v5RD<87Td)?1{SzGxO=pWg#7@UUw--yw56UiWg4@Go;P2@nGFLu0@Vrl{JM2wQQf7zc7vSSVbx817cQ1GeweV+TtR$*nkB) z-RaOelNfjLp2|<9yNZSmRj#Ga1AU}>XUA|9GFPL$3&-?vL^0=n20ozpMg9REeCIlq z;wX>H%JfDk;$nl$gI1%BJwXb4Aj7Wp#Q+;qp`47KJjJ)tFB&|*>h)}>Sx33Y8+c!@ z>AWt1a8!jxB5}9XGW;?rlyxA!d^fk3K-Opl~ z<>HmZF(_rah6XOVyb=}tT`e+MVgz{9ihuMx6vv1`ma{cDM~HMyr+J?(8%w{^e1c79 z`_VSNN#9-9a_=g%-MD)**PHMG8a$LtQ4vYngK#scI{uCkOcqJNR%S3$?>=ONj>Yg0 z6Ro4A3-8Tyk6C&M&WEI#=3WdPk2thb(hTAt1@nMVVmBF`S(a}#y7~`z zZ#*9tf7of8w`~TWt2&)Z#4c!k@CLjk_otI{&HI%A_G_u1sx;-2WBpP}I;fK*y);7I zG-DTtxH-!Ij<6z$)VE3t^swhSdB9ulBS7_5S14~h*fyzwPdRaTB4PW-;u%OUmhQ?a z2xgEeyxv^g-xqGw5SnzVj9A{;I9s%xptv}j8Q(4a@xj;A_0gwopQcN&i8MCqG#J%( zB8z#oR{b0h_1ouM?24Mk>i4$ZRa&54FqcHHtzTYR2#LHhVP*7mP}H8$1lO!ZqeWns zP)gbcZlcr6(^If(ggwguLsMx*%r-=b;+JF4ZJoIEh~f`8)`MrslSW*D{{1cQ#WZUN zr`)7hwg2EB>|F(rL%){My3etnDiPm^5K4Ir{7obp5P+kmFmbb(i9ODxdvfL3LIYqP z;q>^J5kVObDV_lxZ)lY(YG{fOfnnJ^P&1?SG%=+5;bXah^yZfbAV0Ts5rWHWC`4re zr;|@A0=bfSPuOl7qgF61miOU6|K&Sg%7;obuBFn}O{z|XDGG6=p;~6`PEj(`eAHZT zb<42|HP$O%AiN(JO3?@E4(Pq>|Iz8~gR*)3jcknKCq_SEA38q!Utbcp^n(2GH94DS zCAfxE2wjzLiamS7Smkb39&bpV;{^uZ(Uqtw$f#w%wWi#aJDjj^@evjgd6~gH1)p>^ z&uaem%sr=WfIf2N9n}x|F=bkXN8GqoNmsU>p02(heBWs93aHV*Op+xM$;I=-8()13 z$18@gp6h5|VhEEGzs6=2$0Hb^p!p(my!9vm1O1f*yYAa%p*B>4b0u&o1PKHg5S6*pNy&LZj=EDK;?efom+ERB>wdU)0sn&D5%|>S@`RuMW{JvpNk?)P_4bD% z!5A@wMtvGe$o!i_=CK4rl`+*2f%FeWFwT+beS(ILl0k$$H#Ep(lZG|AZaAKMqh3yN z_gXe|wzai&0&7pZKKo%IOQLd5UZ-F#XUZq-Ng{w{A*b z^s?;&xTsX{H(e)c>}UH|yjR{p$2?KL$9__NZF%N(G46Ntx>ql-i39Rgj^-wO6q(dC z(D=}^9-&YsSvB&~IW`=0M113oja%|u6~UU93s{y+8S`FB2OpwlyGnPN1d9xg3jEOU zB66mAL_8`{M`p@^?M0??Py+mF9Z)%s7wWFWc-Vsb1b^>`z10Pbt{ANQHL8JLzgI&?Mw22$m08p0bAH z;zGHV6PKyooScY-3%=JI)Xa>~-L~kZZJhtvaprEXDrcL8HQT+mGmqA-(t4|_zzQ+p zHWv0iXIGk9aTK5;oaBvxQJXCE=7Z?NE_@v4N!(h|FgNU*6IK6&Wji%lALHmkb4=3K z-d>~q4rlkRBpdM{Eb^OU@+aNOMg#UVCG&;UXl@eI^iN~pG63|yCsB5q2wXQ_N^MP7 zZ*Ts*MZ<0%PjBy=t<}}P7faq3i|$46_xkhpE#c(JAyU#KC+P_R!&eAWhL2u(^v=kH zZBXVITwFKJ74Uc7XG;?mICuO#NnbEB2`?y2ECbb}?iy)m z6ndz65YX1+IQ)Qf-N*L#~g(hB9}x337_+Z zF|x|D(g!zl7GCUiE>)({+YTl~7MYMCah?x!p)GmMxEVUgE0KlF&t#1{xBj+mv77Ni zaib@%f3E8?+bhI8mMqE8h!l25WHv86mWoZ{9hux-hmSjBN;B>%*4pkoJ2)W^!ttJK z1SO!1YEG3+hysd;3@K{8{a0U_C2CB%ox)Bk+^g* z)UB?gvo!-vZWC8LcHj-G#mxNN?fLZ|FFyT%DNj9udY|~s9+NivhDEml59X8sp%@ve zXDv_HsO2PplZ+*r&IGb6q7O>;Y~B}TFcdU88gYdwd~{66*YSLoEgjZ_4&^S+#-(C= zj7t|xCD5^&)R0$1f7xNGz0l8$+$gJ=2sM6CeS?dSbM%N1eZ_S3%NpBxr`1bfx4XXM zrnhEZ#fU;^Yc1NebUZe2f0R`_WF*M+yxaNN_3sP)$GR)}JxMG^OmdFJV|i2{*zxXcy!bj?}X%teB@O5#I=UhU8qxhfyC&xqPJt zSMH2pJ8j6FPwc?vh^nr!bpOX~YE9=%8v!Eev*2IL%*i6@o(+r0QA_pVf2w3;5nMir zT?wae1!%P~vY?|xC_*#w38+7^rX)>B){}oBD2lz#7=aVqYsSsOObs@>P*VY#!k&6| z{wzD)R751Bd;{;;o9SlDc2EtiWZroqPcX6pTu4uV3cfLYHa!gx)u{RAp_8H_2ACd4 zsy1i!Brj;(!{a&4_1)AP=kQp3$K=XbjNUsGvO;i>9iu@2LF%J~#Xq|ZfHFx;QV;yO z+?@v{-kA5x?@=7=51is~XkX0*XVB5fHD73Ih{H;ejg$r2iv&rjcmm-NoGkk%*&|O3E=weI_h1 zmKDv$C7%_ting9N67D3xsjue#O>Pxkr&~HDOc`A-AsR0!C@Un1(qSWyVF=_Ij~c#& z$G)-o?Nw1c01skU!It4BC(tQ%-9+ml2d&+(_s^he3azA#JK@4G%&?CTxsR%i4!8;G zY`@g;&0tSW&>l-<+G#sY>FHXvS5GC7pr<6F#aLOE{i2JgXK(lLI6`{I-CxEb{f^g% zkB)d_3&p)6*W|5slv*WEoeCS(XF#ymxmj;q4Gv+IDQ05|+0kpzW(MXoHShNb)EWu!Xs*SWeiq3x}H1m96usCvudR*OCiTJ&I7(i?3du+` z()SA1a@}24xh9$>6Jo;y1i#E*bqatkB3#{1n?f0$#ehsxVcFL8g)%{q}4RYt|lyw z&o8YLnHA(My$%m=Cz51=PdICTAR#L0GkXizO9IF;A`>1-BjQIUJA7>K=_;sSrC>=O zGkFBpBtae6gJdG5`S+?1u^*-8T>l){pM&a(TVUai8t?YH+q*t0`iJ8I95m~m$UKqz zNr5EBBggnSS($7y>%tOgi*bvoSoRB4V_7qWf@Hi!wTeGDx)QI9Q$G2v*BC_UuD z_^7v@$W8ljr_9qmekX5LGMn^fp8bQ$HBm1}qI9yLa=r{!&wBSpcWMPg)fN7X&A(Du z)F(TAghSN0K%dabU@pYU1mW?9SyeH>Hof*eAW+GNI9Pqi)~pN>K`VCRV5kV==ntVt z7ZL_DlLbQT{^4OKlPBPOtah#6HPD{{X>@#}4s;pA7Y*%#q#AYS2&`TtAhrqjhg z*&8$|2i8{i_HIv4E8m-TvF)~1mk*@Ji!uYFGuLcZsU2n9R*~G^rq67?)Op{J6%tcM z2BwI=giRJINS!|D{J(0<@yUavPa9x6{D>et|_vR z&WDX3s{ard*9h{!f>n-gzmMR+L$UoC+?J0V1k1kr31qlm&Y7|e;{2^8zzKXL1#MFy zJ3l(hN?^_Q_$rS}xYY~Z51S*Dnz7SQTBk3S=Pmaik3Rl5^D9`XcqX;f#dZDX$iY1I z?kVl%PL0EtRNUGc=guQjg76x#W&SSGt}D0SX$m*S7x9h(1Q{1x-1tmb4@|J>-6*PO zl}rgLLvz);YEtP||B4a(Z1=#YcYFU%gqH+qe2)2mJoatk23R4{0TK(y@_^Z6AY|Fg zT=9vQ9Wado()<+fkM(RDy#^f)Qq7opFTWPWRRQHyUZqGFEg??uL@oSScx09F`-v?V zJ57#p&y`eFO8ji|S<*=uO2pF}cxm6Lk=a>wJ?88vVw?zHUAIstD|>PN=Z{;{oX*-w zAvu|DVmV^o7@%A^Meog;*t9FEKm^rZb6V{0Moq^Bz?EN3&*a{2Z-WM7+PgjzN86az zcEf65)n^L}?sX9o%vFwD2^kW!X;yl&{Z4?~mhUMh{P?S$FdxgP zrS@(LLwk$sw5-b-Ysb7c%dC179tFKIt~6sx83;kkZbG)hW3xYu={$l9TZASmu6H~t z4C9b&hskN_VOmqGq|TvPj}PI(Rr3i7J(0v*4ERiqzij6YKBbdE)IAt6Er_~4eXV19 z5GM3eqVo+dQv$`1noWDZnRONY!4yLLx6+g?FQxLQsfl;Vn;BAh(nqSYgSz&yDnjN^ zy7kQUGN4WIRh$jDyuuxhF8u(C4IiF*y|ByD!euR~;di%tU0ps=$z#152#>Vo8cO7K z8%wHvHmjI0$@991owHa2c{5sStG`QS6XQg};zYu(^w8CHQD--UKGGFjk=R@iLTk?g z$v(RvD9{gab=GC2!3Ns@bY&s$cKhF&aCpH%Bt!XCg3E$tOQr+NaD5w!#rJ~gXniG^ zeH@7R+2c<<`7)J#k#j4K(VyoL21~!S-Y&W-<5#(wJ&IN_W3~uz)qiTPIO7Q;lVrnE z+Q<<3YWZd(SW+edF+nH<9TH#PZLY{(LgzC1%7Z=qB)v1nBGEiMLe4E@Oa0dm0!;Eu zuP-=KxIGeL*5a_T6PTq+DL-`g0TNTZ(7%MM z3{LJVG#-wg9#bcu2$O1JZEv<|A8Q_PPt6JQy=9Io5Qv1;{&MUV`6!9!$@-R)#=0|+ z4WX90iM(m6Zd8{J^xkuG)9iG!?%p0YaprVKYgg%NZQ&WA4xi!%+3S~~3Z4h|DuXwK~%Y4-A+a33=DdEt>riBzjO z3ojQpw?sy#)DqaZMP&or*TY$qIyT4sM`dz(mL(TCEiJ7>%Tr5`T+su&g@wk_@|Z~< zXCV<$n%8BIqw=*i(SeJj*CSj3Ii?+Trox{!>LUuKV71EK?_-ww=1y+z7iSF>S9=Jz z(Lq{&Dw(8Sy5<4im}ev9c3CSXP!@R`J!;kz<;;DA4nv6QS2~Z~N|rcDbnNP~U1~Z^ zIVK4%i~^G~sCSvV1!P=86(_s{%EK5etw^9Co15MPfpH6NmN;W#!sMF5s1qT$P{~Ab z#at06ZmOc9sjNml>fz3Uz8Zc6Zs0DdWon`%-7v<|XwL&jBxdb(fLug4TvY&-?dqz# zxcIwzrXoLybe$!Mk%myV`bFKZGUa2&N6RIcRSd*9M;1g7`aRNw6C~vfZwz-TZX1GE zNn2A%^M&g)_`{5=VUyBS<;+7}Q=SrfvyZ)WDh<+XYBR6u-)x!TC8Tko zGqP=u29Du2iPSESqr&QnhK8h~VYO_Ko&T>2%D}q1Z^@VEP#hg;mdvO@#$ayPYG?0o zpj8pWLQW>dj0tt3d^tXl+1wCgtv|(G3spxWu5%p|(oSjGdpc1Bhj)LgQ>QH15}|2} z6}Ew9hN7a5j7p$^(GvKw?|d1_dg)JaJqNKwKkM&hL5MCQsa2^C%+d43{dcTrNf{() z--$dY7Q`lz#5}8jbYnowpi%hG<5ju1@X>y3VPSaMEV?!A2$8bu4io>34a5q71hx^i zv!U!q79N}~-@T(tiF6?wbRro<+kD>qKi!NTJA z#Gw0W_pU&t-Ck6a_o1}SLx`1?6%mavu9&_}@Vj3i+FLUN;uByp($>}6jZZRZ8Z2=8c9Y{$izgb_h`dF3wONPXc@ak8r+XeHQr{G}S{{YP1j zV6{bowF+h>A}Ma)>%G*RqrOx7YHSbqq2{VzNas>`@&BZjbDABlT_Q$*=C(vvXZ4rL zZ{){bMAcb<1hyev5EK`U<3iMh%6??uvORMYJSUo((|WRNK&%$^zC2vHy1H@{P)wYq z?2E)apUhp^h6JFeB-1-t{Ygri5{N~^^mL2OX0QRDFLJ;je$!&N;CD8?F=PDC^Tz&o z1Q6LL)#b<6I~k4@3r?O}Je43yfJ!JpNs<^N*W_5`zai$!IdaTdWk@PuDoFwoWX%o8 zmtd>!wOPq+O(Gv?XjR@ukhMEHdZyxcoMilWCrz!GW$kDoF3;!h86cPe0r7TwswGw> zmSPBa&(55i^f;cp)kK4Ms!Li^>%JcNGVSd9!P|;}n)W4Y%E@oHkJkhd!7LmYCY(=$ zi3i8L%`4qwg|Q5zaSL3ElOHPxZARA(xZv9rd%5~&qSqLIjPoju)DyOc5j9%QI`PT8d*YAP{4l}w zQ~TE+P@zPr56#KPTko<<%hbOMJ{df6^bpNkD$zZ@JObw1p&#HXT&MGqpun0ERc5zP z+Zx(W5?fqzPFw&z)_*I1Vd(g`lj`OYzrBp=&m0Y(pv48&Vs;@sHZHEJ38a1ObyGDp zUEeO+SAd?2ti@4F_lh&nrfSQdHSJYrRKu6$I>qO@@UFz9LF)sBj1X5$lmxg$XO;ky zu~YV2Pi|p#g>AV{)~U?HF0iq&vAnLo%5+f!PCp<~{a2oRKPb>nerTL9kcR6peMYM$_Q6lRptaC@lzC?Se#qp#c5YW(G*P zO+_&okCsHfAs$L30rK<5mSkTH1FBP9==F0Iqez6{rbvR-=CkJvl5(+v&`2p$vJ&w? zOx%JhS;%si4=w&DiCmVgWn!FX9olPb7~=WAJocW?^zg}oGLsh(xLot6vs%@8q`tPlk7;P+$f;@eX5NX?R&P7rYgE)N%RorV><%pg_&bJIQ8geD8g)xC0 zC7(jjawId>-qZ6C`k92pD~Hu>9i;H2RGlXOTWaX5f=d6wnNdyrjWc5?-ex>z+Tq37 z;f8W}alkTeKQc?vPC}Zn%dIUT;A*0Mo{Ojg!9k@1JY%$bFd=Vs0)vFi!Y3UC3;NG3vH zoIgKmDt|vUhw?=M(v8Uy^EcC)_CeR{;6E+ze4ZHi-(%Cv3R5%lhh6R7nVK@;{AL?J z5t)w;Uycw02BkEhEVLRp0E)`{Rs$r`pu3~1&Ce4gMCYqb3&AG28ITU`7uv53+#H59SnE*44NBmfb|T+V z1M1(CI1c5pQccY+kGs{Ko!BA8xRa?Q(tr5cxsxZFy2!=Rj+QZ9I`Gisjz8f9_iCfg z4p0Hy;Uk#0BQp#8f-`yz(H3bAY1TAuyEaDi!9JnlJtF_{To`FBPjJ07o>nVXy& zDHITEI&zqaTRu4_X2vAs&?bP+U-A~N<3bb#E=dF3a|4^u>6r=%nt!%Gv@(7mj#>Ai z>Q#WRIRknTl+D(+og<|d3)RiN*KXb~Qm2dyg=rkUJ*-wq6i}L?%ORcjD+DVGMvpGD zrIr9HMts8Gr*1ja7Y={2cS{K98NW-)QwTYQs!GCHnG#H(V}dFn@M!MbInw_gF@ea_ zS*I-rKEST1%fS?~ap)U(H5D)*bu)TQbz1mSyu&B#N(?4+n*kzRcowWO2_;sa04l3W z3zv-Nbq%A?f_;lytIl9VZqa2&LGw1mRCJ{4?)pD!n-b09M;LY^ZFWyf*87VdSeo=q zI2IMn3ciBMXDhhum6a`&zn3hE!%ccMEN<$8B)!Rvi2a;w@mM;VMwp)XVo4cS1C8MA zaaWuU{g-R0M*lFWeZ+-GEk&vzYOcH6)n`LBbR$QBn~G;yDpTQii&*IR&s{kw{{ty$ z+(p%u4uK5Cl6BSN- zakE{^!e|(6=lw8C-tAo8-?@L)$r+029)PIT%wFN#X_U55U!DniLMUTGph(gVuVaBP ziA#k?;N1454wISd^L%W-WUd+}yPUIZ#g&I6P4B`>Mf)gtrGJfu{raHqJ@8%sX>sST zmeQ&L_AF2g18N4qX%0eQO1eDU_yk|-462upg<_NFT&R?$b)Lo zDHAZWPft$)k;M@g*Q*)%Mvb6(!7g_*X?SAm-62T#)y<_MZY0!%y-v5lfjmChSY8eq zhgh`J_?7G;CI2f^?HL_)UEs^}yWXBL?o3~_EB~&-yi?Ue6ZfiUdiCHOMEs*RjeXBj zJ{(M}4Nh%eS-=!G$;oQ%cjqa7659ZPi^8yk={-&We7Oe_pMJhXwhwXPW%Ttl)}AXM%|bKB{^v?@vNjmLfjiv+KM zej>Vqy}QDLLEK@+AR4X!BB3vmas>Fgl3qJlB@x158sy}V##BkLL zQwbm+jDQ9S@?*;wR}0B*+zhhEEEpizD6a7F-bZ>glD0Lx}=0 z#b7NnKQGd*2bZuehf23r?SF*^tK;G-$%bdYJ+d?ep?LiUIc@7q8|sBIbTpnu*A_$V zEW4x_bT&ox_ghZNo1F_1MFHV#k;RFS&$=IN7^MaD3YrLs(}iDdieNEhqn1gWs$9_`OOwej&j576F&;8$_9J+TfBLxKtbW zrF}i-8vw}fUkYpm$?+ul{4H3^jIs4Fu}rFIrA61Orc!pmhXnEc%6}txD_BGI#(TB% z{bL6FMiBgTYqlnUJYY>yYMRsHZ)sqZ4}m7czA9RA@dAK^A~=aUyyTc&Oi(aOStaI$ z0#>h1S>xEr3Xxx}nmWP9?fjt<=v!T8c|9PEdfYDef;N_9tE>IdGuy5&ZMSAk^XSV* z^4vLOZ*~6;-d}c)+dH7_pES(5HRTUP3_oK_QEE|mUHQ2cdSJuHv|{fEfm)2=7P?*?#tejq$i9VhEPvyZ)B7Tj&ap*pP7ErRDixkJ^t z&6YbLT(8SiAq^}$GOr8e@F_ewrA|I;1SMcR=kWO`p78PRGOqT)qBM;co`{Jg^DY$w z<_*PE^P+RBURCPbR=b6P^f93)G*1ce($VA&n4ih97Q)RwhT}q;V*AKq%yNp4es1(Y z*Iz(?zKl%%r6;fpqN{3McITOBpGj=5~X_(E(7zgUMrZtpdy=EW5ur> zL%4P>rUDV?!PNHQo{yKC7iE`=bT7b4E{rx;0|*LpeU8MT-6ExV$-g3vU^HXeB)6~F z=;`VCD0|thV9oo7FPUkhjj&E}11}iAiSRk3`oj_sjKBbYb>~tkM5< z&L32>k2ucauYad=d84U$m%(U{fy;O(8pI1F5gXKDUdY5qrIcl}|set?h^3LVI- zeh!M1VO5QZ5^gc>&W1R%Pk-lO`QMD57~7%L9T_1II*7cqgvP%C#CVYA@kfVWmPQyc zrg5B|UfHK|>JZGOhnDA5e#t`LQxv9L)@^n0kS{kYKQ!;o*RyGlY>e)o0W{AZD0>Ej ziLd+18lWtLrBv06F;7d55wG?t_|ORqUc!>&)O4`8X=0JP6-7anqgRe^^)ILH$h zFCX4qlcoOEg=%_Kl-VUh-+?wJE>ifV7+^fSd?`eE@9%%(9@X#F$&nISI(4M)AO^4R zu+F?c5xY08xGMkcX2;TqD5`Tb>hN$nIh)Z2VDyo3(VN>pe?AT=eX>%?>vAA?_Gfp_ zM@uVf)T^lI9j_1#0Z$1dm(Gmdf&l6~WiOvwnvgt$d@f9B>S9`jDZ9_GOr8$Ai2NBb zUC&n$Wm+DxWJzoVT+BY4W6{0vfN&BS+!*u_I`Mqb5%ImZKIymt0`Fd58g@cdA7IIi zB{BRb|FN*0M3QZfc&qkCiu3vOpvNr6#LFNSV(~2;(%Gk&XbmT^eMWP?M#14w1@1AF zQ)i6KCaaxm54kQ6nb880)uj`dnX!|54WMc^DaDzBM@ZB>5|gx@@%vh(r*0euyj3T#eIdB`=rb9e*O>ShdNzYIV{H5#a9%-Zq+%>w-5H10B} z;zQoh;XDjLw6TwXYWSk=>xyph8xeC7gY+rc_esqB1FQ#-l*BOhZpVpp!2$Q)v-YDU zzYe|%Hyvn%QU3IPS;S4Sk+?ltfG=kjRWlY!1o8lr+M1pbpWc-$5ekJTN79Yr^c8I% z^+mCu7UHs7q(kV+%e_3Wj?d4*7yo)=1l%XVL4)4%?}lV2iLRjuitS{4uz|&tQ64*3 z0!W2adbT*82{IAjo8jl^_E^R#l}U|Ee<8H{KlwK(12Ss|+r$Cg)KbvkvVC*}EM})5 zMFOli955icZ~EU8-EZm9T8ju$f)&Y%Y zHRJ(?L>`a;DyG>ukHMw#UPbJRw%%pv2WZ(oIC$S+_|1t2j4NxV@#E?Wo=YAmutOOn z`z0u@P!{rvEPPDxH5ub|vN~mY$M$s%t~-xK!I2E65w_{pF|Mt~{2WJwXz-qs-#NPh zBoeDIe$_AQaqm$MBp^PZH<1ap5_0MsR}kumgR8fz%C-<}3kjTEz22wodnpIVX=H^K zBx4CL5W41oecD`|1xm+>oNB*KP=*JRLxbJTAj}cy<`w+CZwZtXkR&hZF$?2|B!hOx zanSVA!h?jdJf8%@BG!aeQr3l|mTfbwBA`E;?2IhbBiUo-+uj#pzY&BV0|X)!*;sqE zo#H~`2NH?*Kk>0(2~Qvi>Wt}B)>STBPOqT2YQ-Vyznb`4eB!sy0f|Sqw$hA- zGK(*btBChzitLe?9thgR)H&@{agSXIL_83QSBHB4K10Lyjp!^SeIBOO(|yz4eUtT% z_Vst68{F}YZTa*!brfFEJOR5)zso+_yQ{qWDDRXdA_OYt|m&0r=;C7lt-aytF z8+nl%elt*f|9)h(`|6V8%iUef(wE6|`S-Kx7a?a-tId zzvCH?COnd#$YffokpWU*)!TEu3a~;@8lXI0r?qPXK9+h<7eKY$ohXg>e|SD;ziqcA zy@{J!BC#j5vc|U+m@Sjt;~hGWt%8@-kD;x-p-M1brKh2|^= zxqf-+iE3cKnTYcPs=IcAoN<}NcEiwfv1FnJyd#}fWwpdk8C%ZI`Vb_Ojm!dqMlStgO;*t z0E>P0553tuXRkD)yW6}_Zmlo7A0rL6DDx>DI)W*j194EB33tqXm^b<=EKABmMh}hTK%7;ZsRR`YJSH9+%5o zTU-Aw0-}FUH{BaDF4Prr-a6$Hkss0mejES7&Y$b^C{7U5(YJmS^YIG&HK}jC&yJ7# zPe8HrZYe9RsPO$^AJNAmc`_zQt&u~^Le+`AGcN@}6z_2OM7-13gJGCOB{^@)H;(o2 z+Bm4^X2-VT(0w?o?Uq8B0Q`|WQ%Nd`D!v0jAeN^K;vm?+p1FJKMiS|7GX`MMx(N71#<5CQt8lEhWXR(~_r>(X)OgL$cwS zxdE5~G6^}Q31fo~fm3WD$%B-YY$f!^P?g^}GW~?qlBKswBtkrp^J?+d?S3 z_$CELbkYmt9siOSF7Xj>vN zp4Aw(n2;RqN7O7~%`DB-<>}P)kM&u%p{;G0gguB6aIVDlFwED+51Hr+Cl&GHCn&J zm5X|xpA;vz{SE(Mq@egq_u>u*B?4aa)m8M+SG}0hoz-GIr^8~H8M=W1K@q~Q{kD3&Sv0!I;&jf}@c~-g@Kf7W==)bt-u#UWM`dtYad8L)*rPW|z9pAK&Mw6a_ zW^Fb2@Fk_PhxyNY_YiZ*L~8#}9LL}ITe zPq=7&t_>>a3?273e+Bex;}t|a*#ZxuDrad~*xCOdjsqhq+r3><@Zg>5Mb#FVdX}t$ zXCIAS%v220_jBpWHk4 zlxVYuJ-N4{5d>nV{pW^#|2;Pb*6rNf0|&9+QGnA&)ncf$zGn3BX*vZawy5NnUma@+ zR6W}lLIdp^%V^TIST}spsG!|$D3hPi+PxzKh!{aM^w|nHI5WZn_K?cXTvkePEZ`a_>3_#&&SD0jjat^Q_ zD8Y?jQN zfyhjs5%z>H~G z0x#&Q0qfa`O81wJj@B-q#A!&=Nx!f3_Vo4k{m`QGBUjA-?)GY=vq+HZ9tNR)~{L&hm2RLSdC) zvcs#ZR#i4-b!Z}_<78~1C+K+Wzp5gEIs~!cZnj2dc=m~8z$9Db>A_1G;@cM)5%R2$ zWbq0u@#Q`eZPfMQ^%E3$V?rWkT}r_qPzpel8+;+fV|rFqsAmE2_57jUIiFA3JrXqL zpR{UaJ-lD!Q(vVHJ)pB731^pHCb$rMjOx)4r?)6}A!>m^5*jeqF*u*3H)$DtZ(bmI z3gK&}PYl476C!~Z!nG8D@~#1sUptx@3hlB6t_k3Pn$d*Y@#g5xXn>+^j^8a+rv&>n zNEyQYRpgi45^RyB%=8T3Wen`EnOhScs7`H=$-r!f?Z)0U8xPggng*v_in>r&`_69k zU!Z>D$Vy1k?VrjMrVpUSs`Di4V0aKyeG=w63*i@Mm8R-1n%KI%?VWPZRsLk9mYw+N zCpsyN%BRT6w0m=pKilzjKH7oU{oT**Bu0!XmmYHyl~W91kFpyZQnu5ZJR&2~>&&Yb z%YQmS_n-r5t<%ix^))FmCN1*V#<(L*b>Qab)yF5^&v^WUZRk=G6+TfwAc}E;b2s@! zR?*v)O-gS-JHY?Zbl%}q|Ns9#_DJ>?A$zavGD1d(jLxz5PRJI@-dRVs>~QRzk)0iK zP6(MNJ7lll)BE%Lp02AuT&_A7&+~je#{GV~i<^xA8Q}co3rO+XW9n6OzI{xEnsgTt&gpBxtTP6L6L=cg@8SM@ti!-p<9> ztb2Yvrv7U^W@I`;t~%CX;8OHnF%v)L{EaHub`7@lBDLup#eJ1P$`fCN)XA__>fIi{ zpCj!WC;SOfQdS1``Y=J7n6m(Jqbh^BQQH>dAD%988?9p>ksrtW+$$eue=e8)#?O~l zE~fFGrX@+F-mn-deUG?gYH-3@_8zg^>7O3S_ySc!{k5!Qn6lfy-%A@e|MqTJ)s_ni z+;Y&aa=vyiV;koj#YS+k9%gGXJ!y|&L2mc zp$9o9tTKwBPJ)aVm36Bc)^}rb@M%Mp5P-q38e@X;%-)cufCTNeRL)iIR5S>@N&ABsu z>R_L66K$mEsjEgQlm#xjQCbRN`N-4MSENjmv0@vY3x$$8(69N*(o&7JGj_zMwYOOTHA1~H>J}3 z5#R8E?0q8Kg8gb2n7pKnj8(p+*lB^>b&rBDFtMm6sz^b{V!CAZ+Cmg%CFO$tolf-i z_4&xQwgv3383IG&e{L3FM_FB7IvdlKeT2{ar^$FqrpE5lLK{M!ipKPkAV!da*?I=Oa&- z@5poLs!)(iq7N#5z$ibxOxJ*rI~0`Te}&Td=cjON>{#ILqstc(C%z0tPWT@)wVmib zIqql99bD zQ#AIKC*xN9EFeQL@ySuZ5}oC}tjxXC67MHpScdDm*WTL^sw%Sg4UVJ;>;L$bR(UmR z`0y8Ofc1yL5QXo!Qqp~n4YXH-;UiH}cqm~WC1F0Y9=D=o;I+os+?%6qt^48{OeHGS`-fk{Y-N)}&C!CdId39CT|-fW!Dgbn9B~iuM*%Jk92su$ zcg%Kb3#b}ji=-=ERA@smBP*Tb|Fd> zwDrh#f7jgX?7O%j;6w!lo^K!QuM7vu{hYhcv^eYkcNeSZ=;rF?vI*o*Q~@>@+-A_( z;P!u$!5b4!#C;7l7_lvnAjxGtPfo6zYudYa?;hswfX6`4sI>X}uX&KGGo#V)!YA>> z1|*fZkjj>PhCwP)T_>Y>K{OZz!{CxHTeU51fMa6FqzC2KBMHf5>-dXK?Y#wPY8~DN z*}-*L#VF^nX538j07&v_@iTF zd28Fg)ya-0vn@C`0;pUA%tbu&Y}v5^<}9AsX9-kIQ*4c6n5P_7eIsLMY-+i(zOD1G z9$gH_@P1BnD0o`1WG}XRlt0JmJs$6>4W@eq4>nd0!BUgz3^-K*Y~>U{bm@ulA|h`2 z=F$1Je30N4TH=kJChfiXFWxuT?H42qP`-6QDFF3(-fG@% z5J=EE&qmV{I)J1WoPmM8pga!$3w6BVwdLWej_XlZ@O}S2pIjFtJiY`orqkcobHV*} zho&x#nUV{{cs-_aA}VP%pQ1(Z-;O$60>kTftZ0UV9{iPQ2%_Hmx?TNcX20UeH6#*b*3|llsl{bVRcaE@M_Fn_Bhe z(Bn%bGO+!ZeLDU`K`--yXh>amOfV!?H7~{B9rD-x$dA47My_Zv5M44lC8pMtxdZF( zj?vM}0TRt32i5|Yru}d_#2Vl5@FbRHmbE(fo^e*M1?|zr6&K-nu5lcm8Q3}l0S8k; zlxiqHUc?0wzOoPIVx^0FYLc|!EEB&M+Wp5`5+0xbJ>L1>t@5<#6=RK^x#gZ;`yWKp zSpUl4%*;%P_ns><)9h*veRCjqlfjmm&xMJlLkrvLJU?LAkGt3`uVQ)}P-|TIwBYYk zx%1OMf7uA}3^P|}rT}2@ZQwPZ=8E^?+Y?07E^)jHECw zn$M>s1ryiF1xhXQoCHFeI-$Nhgp{^;Vb@L?%B8QhPW$+7@m0Y3vef+Y;Jl z8y=h*TvlXmC$Becr(jx}{MBG)-7%}#wKf^@?YAtPX^5$?dNwDx`GfPd`v4$3Tzm6QHsT%!BR=LGR3mlup&B?|6autHgrG^ z4N+bLF(!MX<^qVM2mz^0Tt(R@5auJp%9Q@32Dl5NUgu#VFLKuL>|mNZ7oJDJP@{XH zF&k}`$?JZlVLbljP=>&Y;>NOFX8*uli6d7P({PZ>~$huX7Haw>9;Pmw0adw>W z`ik7e(tOiGs|R{)XU^{C=;pEtP=G*W${A3}PaxWbVnN1IclF5`+6?G!z+Yz|(AvaR zUAc9UBs^sQwImd=?8@}`?BA3&SUtGAou%L(AII$gx(|Hd|f9Q^Krpn4-+Y`Md>%yQotNjNla$le& zmaJIu0JjNYW|1;^iCfaE6|vzMpG?4PnuO=evnm#;;YkyPa1r{q3q^NxZ6>Hh^oK4J z4H1QgR`b5EeQGTnW~)X+0m9rM-(XG%PCZyU*ESXg4Gu)Ct}NqHa!=*@tgL%>h(J!e z64k+juy}J9;r_zIrm}Xw+MmTxlr|t+r1hF=jiX>3LX;DSw4jY1=Fme3_9DdF;*HGS z#T$2`zN)W%_scu?d!*g!^P=}h-~_{Ol`SI%V<)~CU0_%ve-o-S3$eFt3Gp|7u5S4B z+t>J+*)nsEKA6%Z26HHnm_m?P(37C0|I-3Ie!UR2Rn8yN_AU9=O*($Ssm+v|D+k!> zfb-D;`sSQr1>9oJjUh3$hwp_shHK9PK(|hc4J;GtlP~?m&#$g0CIF>J0Sb?Zi16P1 zrK6skl?BQLTC0yOd>TbR5JmuESaoxLMe-QG;Lk#PQ=GU3C9Xwe^m8}TZiEruUVG5Z z_0viXvi z%t_AotHG(1O(jdR4vTQm&hycFHmQ$hdh6=k)X2)%i5AM+j+) z8tFgg(H!}=V|cJPQBU#@PfBO5r9TVjj?TAjHXv^k@mg4$TT)*2Gf@tG9+S2i?4A~e z=Gb^Zf@}@vdDr{Q1xL^@t=tbw3Rj+$s7Gk?$hun?!lLs?yZsv3+2!5IXxvHUst3P1 z36D3Hso#(06Yl)eW!U+5cD-Tw?`%vtgTlkYFyMh53r!WO=@2$AUwdN8X(N$nsOqdV zo#KeC^EwH6gx9uy*LF{7a$|mfV`thiy%$LHGDpuZFO4z3ANes3fGwDbKw8I0+bGzQ zc!&$SqfK-uk+AWxy79vR2H<@dU!Rf{#0|M_O6XwEUwZBHy4p2Xm;)b^LYa(}iHVVM zUOGv*$t_C~4f(q}{{_^pa&oe5o+rCA;D%{>H7_j*P+EY(>ElXvWi1kb&Taxj$%~z) zJpd+Ovx&f6IgWycZHNayDs4(`X8T1S!Ng(%yXlLPJq(cQ=AQ3r-Y?HvR&jqH3Zzv_(fAe|&+q^5 zIi?3;k*Bhby3S+G7_rgJpu@Imq%R3F(PP24Ea`h}?AK>R^xsLtR3cz>dyjmmd+xH9 zr5Gxst(cghUu%T4X4gH#+L3; zp&IykqkGGj6qjs@g*CL%B39|sF@68UMGU98erYli$N@n=>{$Qkd&h?N^+i0mO~&n? zmI_`B)pv~TEFc-A$|TP68x9O*?y8so<*s}RRBw76^+LPZslSTj41yU_g*))gI3c{k z7v-~XPnJg+Qs(Eqct3Xc*INe2yYh6XLq?%P2HPO0V6{qO`ebJWmXClf3lz4p;}`%g zQUrzDe*QozS|zd7=WYsTXH^fSQ3m`~{P!Snc6VndP9>zvT8nCv1qXI^!VuuPK^XZ%y+EMr?b;m-tL56j{js*T=G^*m6z%|{r67)2LKN&T zSPN>_x_0{h%hV3?2n+6B%w#I*9YgCz+Pxr^AfYH*9KHV>NS(K?XQqGgH3Z*AO>Lj*Lzh3by443`2}rQVb4oxsgu*%1fLa7j z4K<|<<0mv+W<RbP%8Yf;gBoQk8<|3CH)Vz|O@z@S_L}Dy5=Q*3q;MwBE)`ffM_e`)nMN67uDy+f6;QaIeR<$y8UAtqZl|f{SqbA;W>&RSVhji@s-nMeeU`}_)yr~dvzZ!H6oOEUE-R@v`Cb0N zLWea*Ac|*>nd8UB#nX0EM;C|KLGV<$BR{g-XX`7Ktp!BwUSIg5mnUv15|1psM9j+% zsQ-%NW7d45K{L|Pz4i5_!={JSiz|@gCPee* zz^M$w^Y-&Q_}vxKv^U@Gf3p4O4-)VVJ1>{Z z`$RBaUv+@I5;4NsiqqSQeR2zZKr5Z(9pu} zDy?6-X4ebNLHwSEFD}zKUr7tKyiv9{5mWR`hHsHuj>oQ9o#O>vH)~?PP;9L5>_?&4 z%MYo#fkDQIxZlD7>E*;n|5=WY`O{0R2kju+(>JG2fG} zkj|?woy1Q(+}#6CcISgEZi419_ZW7Nm3(q&iiL3~}I;WA`u%#PDfNO5Vnb5pH4VL5PrNvVs`FlnxZf-QVps z)uqrelc{6+WE~ZkkPoutKdc;`GV!f(U0rl2(;MP3F4|2=!;?O-=tin%aTmqT6YkWU zsiL^=nfI@U3i=%eSLRYe9 zm)HJ^S-giatu^Sg5@Uulf~~3~kDz13uhrsY?R6w24E8YHFehaq?vHS%Od3kgxlbqc z!6yB~cTh|q4D^j$GTe1Oe10!x$zd7S!4)H>g&IC`@1Sf#Pr{TPh5qc_zxU7oku6v` zT;N?a#mA5I+D{TvL8{R{nM8geAWEoff9HYAn@&04W9t8y&t+E*{Z68_mlMBy3Q7#Q z)2@hRdj%!5;SH)uTVTDME&3nVrt-w^Kc}(6r zf1(#e?M{v71e~qWfHQN1G7B30O0v5sBOB->^6@JNe?S(cO%ewP!{ZwU7ndGBecxHt z7##$2S-??Q7+D_HCF3urqlyuH_)0<*53;HL?jO>f|FerOoiXokN3!S1=&#+a`bp7+ zT)ZAiIw}+e9yU>>-TQeG`0Xc^TqdTGA-m>6=Ifx!i@=)ND}pvcU5D5NK|ia$hcT%7 zz`)_KtzL=Jv7pCZh@Qm~6c0M9%1g2j$P^!?6DFoiMklWJJP?mHy>6&XVb3`EePtcV zclaJ+z0-7&g-UgGuAi#*@2IrEf)__?-!<1TP-q5dyNPb_k-d1kSNZCE42D)p z2od2jWk7h+hKJmupKAH`ezhC|M#nE0!$3{55(uydOSW>d0k=eF!+oSbiTtd+uI0O! z;OKO~IT{xvo$yDhI~2qbbPH`O>jVkWf(v58UY6;^?+R$=wvaWGt>O=rtlop$TJ+|Y zfWZ}AP+(M2!U>V2k0W!FyoaZNpGQm_zdP@LG4H*2Q73mXVJ~KkKz5#kao72@gs