66import re
77import time
88from dataclasses import dataclass
9- from typing import List , Literal , Optional , Tuple , Union
9+ from typing import Dict , Iterable , List , Literal , Optional , Tuple , Union
1010
1111from pylabrobot .resources import Plate , Well
1212
@@ -72,6 +72,54 @@ def retry(func, *args, **kwargs):
7272 time .sleep (delay )
7373
7474
75+ def _non_overlapping_rectangles (
76+ points : Iterable [Tuple [int , int ]],
77+ ) -> List [Tuple [int , int , int , int ]]:
78+ """Find non-overlapping rectangles that cover all given points.
79+
80+ Example:
81+ >>> points = [
82+ >>> (1, 1),
83+ >>> (2, 2), (2, 3), (2, 4),
84+ >>> (3, 2), (3, 3), (3, 4),
85+ >>> (4, 2), (4, 3), (4, 4), (4, 5),
86+ >>> (5, 2), (5, 3), (5, 4), (5, 5),
87+ >>> (6, 2), (6, 3), (6, 4), (6, 5),
88+ >>> (7, 2), (7, 3), (7, 4),
89+ >>> ]
90+ >>> non_overlapping_rectangles(points)
91+ [
92+ (1, 1, 1, 1),
93+ (2, 2, 7, 4),
94+ (4, 5, 6, 5),
95+ ]
96+ """
97+
98+ pts = set (points )
99+ rects = []
100+
101+ while pts :
102+ # start a rectangle from one arbitrary point
103+ r0 , c0 = min (pts )
104+ # expand right
105+ c1 = c0
106+ while (r0 , c1 + 1 ) in pts :
107+ c1 += 1
108+ # expand downward as long as entire row segment is filled
109+ r1 = r0
110+ while all ((r1 + 1 , c ) in pts for c in range (c0 , c1 + 1 )):
111+ r1 += 1
112+
113+ rects .append ((r0 , c0 , r1 , c1 ))
114+ # remove covered points
115+ for r in range (r0 , r1 + 1 ):
116+ for c in range (c0 , c1 + 1 ):
117+ pts .discard ((r , c ))
118+
119+ rects .sort ()
120+ return rects
121+
122+
75123class Cytation5Backend (ImageReaderBackend ):
76124 """Backend for biotek cytation 5 image reader.
77125
@@ -564,16 +612,14 @@ async def set_temperature(self, temperature: float):
564612 async def stop_heating_or_cooling (self ):
565613 return await self .send_command ("g" , "00000" )
566614
567- def _parse_body (self , body : bytes ) -> List [ List [ Optional [ float ]] ]:
568- start_index = body . index ( b"01,01" )
615+ def _parse_body (self , body : bytes ) -> Dict [ Tuple [ int , int ], float ]:
616+ start_index = 22
569617 end_index = body .rindex (b"\r \n " )
570618 num_rows = 8
571619 rows = body [start_index :end_index ].split (b"\r \n ," )[:num_rows ]
572620
573621 assert self ._plate is not None , "Plate must be set before reading data"
574- parsed_data : List [List [Optional [float ]]] = [
575- [None for _ in range (self ._plate .num_items_x )] for _ in range (self ._plate .num_items_y )
576- ]
622+ parsed_data : Dict [Tuple [int , int ], float ] = {}
577623 for row in rows :
578624 values = row .split (b"," )
579625 grouped_values = [values [i : i + 3 ] for i in range (0 , len (values ), 3 )]
@@ -583,10 +629,20 @@ def _parse_body(self, body: bytes) -> List[List[Optional[float]]]:
583629 row_index = int (group [0 ].decode ()) - 1 # 1-based index in the response
584630 column_index = int (group [1 ].decode ()) - 1 # 1-based index in the response
585631 value = float (group [2 ].decode ())
586- parsed_data [row_index ][ column_index ] = value
632+ parsed_data [( row_index , column_index ) ] = value
587633
588634 return parsed_data
589635
636+ def _data_dict_to_list (
637+ self , data : Dict [Tuple [int , int ], float ], plate : Plate
638+ ) -> List [List [Optional [float ]]]:
639+ result : List [List [Optional [float ]]] = [
640+ [None for _ in range (plate .num_items_x )] for _ in range (plate .num_items_y )
641+ ]
642+ for (row , col ), value in data .items ():
643+ result [row ][col ] = value
644+ return result
645+
590646 async def set_plate (self , plate : Plate ):
591647 # 08120112207434014351135308559127881422
592648 # ^^^^ plate size z
@@ -640,24 +696,14 @@ async def set_plate(self, plate: Plate):
640696 self ._plate = plate
641697 return resp
642698
643- def _get_min_max_row_col (self , wells : List [Well ], plate : Plate ) -> Tuple [int , int , int , int ]:
699+ def _get_min_max_row_col_tuples (
700+ self , wells : List [Well ], plate : Plate
701+ ) -> List [Tuple [int , int , int , int ]]: # min_row, min_col, max_row, max_col
644702 # check if all wells are in the same plate
645703 plates = set (well .parent for well in wells )
646704 if len (plates ) != 1 or plates .pop () != plate :
647705 raise ValueError ("All wells must be in the specified plate" )
648-
649- # check if wells are in a grid
650- rows = sorted (set (well .get_row () for well in wells ))
651- columns = sorted (set (well .get_column () for well in wells ))
652- min_row , max_row , min_col , max_col = rows [0 ], rows [- 1 ], columns [0 ], columns [- 1 ]
653- if (
654- (len (rows ) * len (columns ) != len (wells ))
655- or rows != list (range (min_row , max_row + 1 ))
656- or columns != list (range (min_col , max_col + 1 ))
657- ):
658- raise ValueError ("Wells must be in a grid" )
659-
660- return min_row , max_row , min_col , max_col
706+ return _non_overlapping_rectangles ((well .get_row (), well .get_column ()) for well in wells )
661707
662708 async def read_absorbance (
663709 self , plate : Plate , wells : List [Well ], wavelength : int
@@ -668,19 +714,22 @@ async def read_absorbance(
668714 await self .set_plate (plate )
669715
670716 wavelength_str = str (wavelength ).zfill (4 )
671- min_row , max_row , min_col , max_col = self ._get_min_max_row_col (wells , plate )
672- cmd = f"004701{ min_row + 1 :02} { min_col + 1 :02} { max_row + 1 :02} { max_col + 1 :02} 000120010000110010000010600008{ wavelength_str } 1"
673- checksum = str (sum (cmd .encode ()) % 100 ).zfill (2 )
674- cmd = cmd + checksum + "\x03 "
675- await self .send_command ("D" , cmd )
717+ data : Dict [Tuple [int , int ], float ] = {}
676718
677- resp = await self .send_command ("O" )
678- assert resp == b"\x06 0000\x03 "
719+ for min_row , min_col , max_row , max_col in self ._get_min_max_row_col_tuples (wells , plate ):
720+ cmd = f"004701{ min_row + 1 :02} { min_col + 1 :02} { max_row + 1 :02} { max_col + 1 :02} 000120010000110010000010600008{ wavelength_str } 1"
721+ checksum = str (sum (cmd .encode ()) % 100 ).zfill (2 )
722+ cmd = cmd + checksum + "\x03 "
723+ await self .send_command ("D" , cmd )
679724
680- # read data
681- body = await self ._read_until (b"\x03 " , timeout = 60 * 3 )
682- assert resp is not None
683- return self ._parse_body (body )
725+ resp = await self .send_command ("O" )
726+ assert resp == b"\x06 0000\x03 "
727+
728+ # read data
729+ body = await self ._read_until (b"\x03 " , timeout = 60 * 3 )
730+ assert resp is not None
731+ data .update (self ._parse_body (body ))
732+ return self ._data_dict_to_list (data , plate )
684733
685734 async def read_luminescence (
686735 self , plate : Plate , wells : List [Well ], focal_height : float , integration_time : float = 1
@@ -704,22 +753,23 @@ async def read_luminescence(
704753 integration_time_seconds_s = str (integration_time_seconds * 5 ).zfill (2 )
705754 integration_time_milliseconds_s = str (int (float (integration_time_milliseconds * 50 ))).zfill (2 )
706755
707- min_row , max_row , min_col , max_col = self . _get_min_max_row_col ( wells , plate )
708-
709- 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
710- checksum = str ((sum (cmd .encode ()) + 8 ) % 100 ).zfill (2 ) # don't know why +8
711- cmd = cmd + checksum
712- await self .send_command ("D" , cmd )
756+ data : Dict [ Tuple [ int , int ], float ] = {}
757+ for min_row , min_col , max_row , max_col in self . _get_min_max_row_col_tuples ( wells , plate ):
758+ 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
759+ checksum = str ((sum (cmd .encode ()) + 8 ) % 100 ).zfill (2 ) # don't know why +8
760+ cmd = cmd + checksum
761+ await self .send_command ("D" , cmd )
713762
714- resp = await self .send_command ("O" )
715- assert resp == b"\x06 0000\x03 "
763+ resp = await self .send_command ("O" )
764+ assert resp == b"\x06 0000\x03 "
716765
717- # 2m10s of reading per 1 second of integration time
718- # allow 60 seconds flat
719- timeout = 60 + integration_time_seconds * (2 * 60 + 10 )
720- body = await self ._read_until (b"\x03 " , timeout = timeout )
721- assert body is not None
722- return self ._parse_body (body )
766+ # 2m10s of reading per 1 second of integration time
767+ # allow 60 seconds flat
768+ timeout = 60 + integration_time_seconds * (2 * 60 + 10 )
769+ body = await self ._read_until (b"\x03 " , timeout = timeout )
770+ assert body is not None
771+ data .update (self ._parse_body (body ))
772+ return self ._data_dict_to_list (data , plate )
723773
724774 async def read_fluorescence (
725775 self ,
@@ -743,21 +793,24 @@ async def read_fluorescence(
743793
744794 excitation_wavelength_str = str (excitation_wavelength ).zfill (4 )
745795 emission_wavelength_str = str (emission_wavelength ).zfill (4 )
746- min_row , max_row , min_col , max_col = self ._get_min_max_row_col (wells , plate )
747- cmd = (
748- f"008401{ min_row + 1 :02} { min_col + 1 :02} { max_row + 1 :02} { max_col + 1 :02} 0001200100001100100000135000100200200{ excitation_wavelength_str } 000"
749- f"{ emission_wavelength_str } 000000000000000000210011"
750- )
751- checksum = str ((sum (cmd .encode ()) + 7 ) % 100 ).zfill (2 ) # don't know why +7
752- cmd = cmd + checksum + "\x03 "
753- resp = await self .send_command ("D" , cmd )
754796
755- resp = await self .send_command ("O" )
756- assert resp == b"\x06 0000\x03 "
797+ data : Dict [Tuple [int , int ], float ] = {}
798+ for min_row , min_col , max_row , max_col in self ._get_min_max_row_col_tuples (wells , plate ):
799+ cmd = (
800+ f"008401{ min_row + 1 :02} { min_col + 1 :02} { max_row + 1 :02} { max_col + 1 :02} 0001200100001100100000135000100200200{ excitation_wavelength_str } 000"
801+ f"{ emission_wavelength_str } 000000000000000000210011"
802+ )
803+ checksum = str ((sum (cmd .encode ()) + 7 ) % 100 ).zfill (2 ) # don't know why +7
804+ cmd = cmd + checksum + "\x03 "
805+ resp = await self .send_command ("D" , cmd )
806+
807+ resp = await self .send_command ("O" )
808+ assert resp == b"\x06 0000\x03 "
757809
758- body = await self ._read_until (b"\x03 " , timeout = 60 * 2 )
759- assert body is not None
760- return self ._parse_body (body )
810+ body = await self ._read_until (b"\x03 " , timeout = 60 * 2 )
811+ assert body is not None
812+ data .update (self ._parse_body (body ))
813+ return self ._data_dict_to_list (data , plate )
761814
762815 async def _abort (self ) -> None :
763816 await self .send_command ("x" , wait_for_response = False )
0 commit comments