pychemstation.control.hplc
Module to provide API for the remote control of the Agilent HPLC systems.
HPLCController sends commands to Chemstation software via a command file. Answers are received via reply file. On the Chemstation side, a custom Macro monitors the command file, executes commands and writes to the reply file. Each command is given a number (cmd_no) to keep track of which commands have been processed.
Authors: Alexander Hammer, Hessam Mehr, Lucy Hao
1""" 2Module to provide API for the remote control of the Agilent HPLC systems. 3 4HPLCController sends commands to Chemstation software via a command file. 5Answers are received via reply file. On the Chemstation side, a custom 6Macro monitors the command file, executes commands and writes to the reply file. 7Each command is given a number (cmd_no) to keep track of which commands have 8been processed. 9 10Authors: Alexander Hammer, Hessam Mehr, Lucy Hao 11""" 12 13import logging 14import os 15import time 16from typing import Union 17 18import polling 19from xsdata.formats.dataclass.parsers import XmlParser 20 21from .chromatogram import AgilentHPLCChromatogram, TIME_FORMAT, SEQUENCE_TIME_FORMAT 22from ..generated import PumpMethod, DadMethod 23from ..utils.constants import MAX_CMD_NO, SEQUENCE_TABLE, METHOD_TIMETABLE 24from ..utils.hplc_param_types import HPLCAvailStatus, Command, HPLCErrorStatus, Table, TableOperation, SequenceTable, \ 25 SequenceEntry, RegisterFlag, MethodTimetable, Param, PType, str_to_status, HPLCRunningStatus, Entry, \ 26 HPLCMethodParams 27 28 29class HPLCController: 30 """ 31 Class to control Agilent HPLC systems via Chemstation Macros. 32 """ 33 34 def __init__( 35 self, 36 comm_dir: str, 37 data_dir: str, 38 method_dir: str, 39 sequence_dir: str, 40 cmd_file: str = "cmd", 41 reply_file: str = "reply", 42 ): 43 """Initialize HPLC controller. The `hplc_talk.mac` macro file must be loaded in the Chemstation software. 44 `comm_dir` must match the file path in the macro file. 45 46 :param comm_dir: Name of directory for communication, where ChemStation will read and write from. Can be any existing directory. 47 :param data_dir: Name of directory that ChemStation saves run data. Must be accessible by ChemStation. 48 :param cmd_file: Name of command file 49 :param reply_file: Name of reply file 50 :raises FileNotFoundError: If either `data_dir`, `method_dir` or `comm_dir` is not a valid directory. 51 """ 52 if os.path.isdir(comm_dir): 53 self.cmd_file = os.path.join(comm_dir, cmd_file) 54 self.reply_file = os.path.join(comm_dir, reply_file) 55 self.cmd_no = 0 56 else: 57 raise FileNotFoundError(f"comm_dir: {comm_dir} not found.") 58 self._most_recent_hplc_status = None 59 60 if os.path.isdir(data_dir): 61 self.data_dir = data_dir 62 else: 63 raise FileNotFoundError(f"data_dir: {data_dir} not found.") 64 65 if os.path.isdir(method_dir): 66 self.method_dir = method_dir 67 else: 68 raise FileNotFoundError(f"method_dir: {method_dir} not found.") 69 70 if os.path.isdir(sequence_dir): 71 self.sequence_dir = sequence_dir 72 else: 73 raise FileNotFoundError(f"method_dir: {method_dir} not found.") 74 75 self.spectra = { 76 "A": AgilentHPLCChromatogram(self.data_dir), 77 "B": AgilentHPLCChromatogram(self.data_dir), 78 "C": AgilentHPLCChromatogram(self.data_dir), 79 "D": AgilentHPLCChromatogram(self.data_dir), 80 } 81 82 self.data_files: list[str] = [] 83 self.internal_variables: list[dict[str, str]] = [] 84 85 # Create files for Chemstation to communicate with Python 86 open(self.cmd_file, "a").close() 87 open(self.reply_file, "a").close() 88 89 self.logger = logging.getLogger("hplc_logger") 90 self.logger.addHandler(logging.NullHandler()) 91 92 self.reset_cmd_counter() 93 94 self.logger.info("HPLC Controller initialized.") 95 96 def _set_status(self): 97 """Updates current status of HPLC machine""" 98 self._most_recent_hplc_status = self.status()[0] 99 100 def _check_data_status(self) -> bool: 101 """Checks if HPLC machine is in an available state, meaning a state that data is not being written. 102 103 :return: whether the HPLC machine is in a safe state to retrieve data back.""" 104 old_status = self._most_recent_hplc_status 105 self._set_status() 106 file_exists = os.path.exists(self.data_files[-1]) if len(self.data_files) > 0 else False 107 done_writing_data = isinstance(self._most_recent_hplc_status, 108 HPLCAvailStatus) and old_status != self._most_recent_hplc_status and file_exists 109 return done_writing_data 110 111 def check_hplc_ready_with_data(self) -> bool: 112 """Checks if ChemStation has finished writing data and can be read back. 113 114 :param method: if you are running a method and want to read back data, the timeout period will be adjusted to be longer than the method's runtime 115 116 :return: Return True if data can be read back, else False. 117 """ 118 self._set_status() 119 120 timeout = 10 * 60 121 hplc_run_done = polling.poll( 122 lambda: self._check_data_status(), 123 timeout=timeout, 124 step=30 125 ) 126 127 return hplc_run_done 128 129 def _send(self, cmd: str, cmd_no: int, num_attempts=5) -> None: 130 """Low-level execution primitive. Sends a command string to HPLC. 131 132 :param cmd: string to be sent to HPLC 133 :param cmd_no: Command number 134 :param num_attempts: Number of attempts to send the command before raising exception. 135 :raises IOError: Could not write to command file. 136 """ 137 err = None 138 for _ in range(num_attempts): 139 time.sleep(1) 140 try: 141 with open(self.cmd_file, "w", encoding="utf8") as cmd_file: 142 cmd_file.write(f"{cmd_no} {cmd}") 143 except IOError as e: 144 err = e 145 self.logger.warning("Failed to send command; trying again.") 146 continue 147 else: 148 self.logger.info("Sent command #%d: %s.", cmd_no, cmd) 149 return 150 else: 151 raise IOError(f"Failed to send command #{cmd_no}: {cmd}.") from err 152 153 def _receive(self, cmd_no: int, num_attempts=100) -> str: 154 """Low-level execution primitive. Recives a response from HPLC. 155 156 :param cmd_no: Command number 157 :param num_attempts: Number of retries to open reply file 158 :raises IOError: Could not read reply file. 159 :return: ChemStation response 160 """ 161 err = None 162 for _ in range(num_attempts): 163 time.sleep(1) 164 165 try: 166 with open(self.reply_file, "r", encoding="utf_16") as reply_file: 167 response = reply_file.read() 168 except OSError as e: 169 err = e 170 self.logger.warning("Failed to read from reply file; trying again.") 171 continue 172 173 try: 174 first_line = response.splitlines()[0] 175 response_no = int(first_line.split()[0]) 176 except IndexError as e: 177 err = e 178 self.logger.warning("Malformed response %s; trying again.", response) 179 continue 180 181 # check that response corresponds to sent command 182 if response_no == cmd_no: 183 self.logger.info("Reply: \n%s", response) 184 return response 185 else: 186 self.logger.warning( 187 "Response #: %d != command #: %d; trying again.", 188 response_no, 189 cmd_no, 190 ) 191 continue 192 else: 193 raise IOError(f"Failed to receive reply to command #{cmd_no}.") from err 194 195 def sleepy_send(self, cmd: Union[Command, str]): 196 self.send("Sleep 0.1") 197 self.send(cmd) 198 self.send("Sleep 0.1") 199 200 def send(self, cmd: Union[Command, str]): 201 """Sends a command to Chemstation. 202 203 :param cmd: Command to be sent to HPLC 204 """ 205 if self.cmd_no == MAX_CMD_NO: 206 self.reset_cmd_counter() 207 208 cmd_to_send: str = cmd.value if isinstance(cmd, Command) else cmd 209 self.cmd_no += 1 210 self._send(cmd_to_send, self.cmd_no) 211 212 def receive(self) -> str: 213 """Returns messages received in reply file. 214 215 :return: ChemStation response 216 """ 217 return self._receive(self.cmd_no) 218 219 def reset_cmd_counter(self): 220 """Resets the command counter.""" 221 self._send(Command.RESET_COUNTER_CMD.value, cmd_no=MAX_CMD_NO + 1) 222 self._receive(cmd_no=MAX_CMD_NO + 1) 223 self.cmd_no = 0 224 225 self.logger.debug("Reset command counter") 226 227 def sleep(self, seconds: int): 228 """Tells the HPLC to wait for a specified number of seconds. 229 230 :param seconds: number of seconds to wait 231 """ 232 self.send(Command.SLEEP_CMD.value.format(seconds=seconds)) 233 self.logger.debug("Sleep command sent.") 234 235 def standby(self): 236 """Switches all modules in standby mode. All lamps and pumps are switched off.""" 237 self.send(Command.STANDBY_CMD) 238 self.logger.debug("Standby command sent.") 239 240 def preprun(self): 241 """ Prepares all modules for run. All lamps and pumps are switched on.""" 242 self.send(Command.PREPRUN_CMD) 243 self.logger.debug("PrepRun command sent.") 244 245 def status(self) -> list[Union[HPLCRunningStatus, HPLCAvailStatus, HPLCErrorStatus]]: 246 """Get device status(es). 247 248 :return: list of ChemStation's current status 249 """ 250 self.send(Command.GET_STATUS_CMD) 251 time.sleep(1) 252 253 try: 254 parsed_response = self.receive().splitlines()[1].split()[1:] 255 except IOError: 256 return [HPLCErrorStatus.NORESPONSE] 257 except IndexError: 258 return [HPLCErrorStatus.MALFORMED] 259 recieved_status = [str_to_status(res) for res in parsed_response] 260 self._most_recent_hplc_status = recieved_status[0] 261 return recieved_status 262 263 def stop_macro(self): 264 """Stops Macro execution. Connection will be lost.""" 265 self.send(Command.STOP_MACRO_CMD) 266 267 def add_table_row(self, table: Table): 268 """Adds a row to the provided table for currently loaded method or sequence. 269 Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants. 270 You can also provide your own table. 271 272 :param table: the table to add a new row to 273 """ 274 self.sleepy_send(TableOperation.NEW_ROW.value.format(register=table.register, table_name=table.name)) 275 276 def delete_table(self, table: Table): 277 """Deletes the table for the current loaded method or sequence. 278 Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants. 279 You can also provide your own table. 280 281 :param table: the table to delete 282 """ 283 self.sleepy_send(TableOperation.DELETE_TABLE.value.format(register=table.register, table_name=table.name)) 284 285 def new_table(self, table: Table): 286 """Creates the table for the currently loaded method or sequence. 287 Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants. 288 You can also provide your own table. 289 290 :param table: the table to create 291 """ 292 self.send(TableOperation.CREATE_TABLE.value.format(register=table.register, table_name=table.name)) 293 294 def _get_table_rows(self, table: Table) -> str: 295 self.send(TableOperation.GET_OBJ_HDR_VAL.value.format(internal_val="Rows", 296 register=table.register, 297 table_name=table.name, 298 col_name=RegisterFlag.NUM_ROWS, )) 299 res = self.receive() 300 self.send("Sleep 1") 301 self.send('Print Rows') 302 return res 303 304 def edit_sequence_table(self, sequence_table: SequenceTable): 305 """Updates the currently loaded sequence table with the provided table. This method will delete the existing sequence table and remake it. 306 If you would only like to edit a single row of a sequence table, use `edit_sequence_table_row` instead. 307 308 :param sequence_table: 309 """ 310 self.send("Local Rows") 311 self.sleep(1) 312 self.delete_table(SEQUENCE_TABLE) 313 self.sleep(1) 314 self.new_table(SEQUENCE_TABLE) 315 self.sleep(1) 316 self._get_table_rows(SEQUENCE_TABLE) 317 for _ in sequence_table.rows: 318 self.add_table_row(SEQUENCE_TABLE) 319 self.sleep(1) 320 self.send(Command.SAVE_SEQUENCE_CMD) 321 self.sleep(1) 322 self._get_table_rows(SEQUENCE_TABLE) 323 self.send(Command.SWITCH_SEQUENCE_CMD) 324 for i, row in enumerate(sequence_table.rows): 325 self.edit_sequence_table_row(row=row, row_num=i + 1) 326 self.sleep(1) 327 self.send(Command.SAVE_SEQUENCE_CMD) 328 self.sleep(1) 329 330 def edit_sequence_table_row(self, row: SequenceEntry, row_num: int): 331 """Edits a row in the sequence table. Assumes the row already exists. 332 333 :param row: sequence row entry with updated information 334 :param row_num: the row to edit, based on 1-based indexing 335 """ 336 337 if row.vial_location: 338 self.sleepy_send(TableOperation.EDIT_ROW_VAL.value.format(register=SEQUENCE_TABLE.register, 339 table_name=SEQUENCE_TABLE.name, 340 row=row_num, 341 col_name=RegisterFlag.VIAL_LOCATION, 342 val=row.vial_location)) 343 if row.method: 344 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 345 table_name=SEQUENCE_TABLE.name, 346 row=row_num, 347 col_name=RegisterFlag.METHOD, 348 val=row.method)) 349 350 if row.num_inj: 351 self.sleepy_send(TableOperation.EDIT_ROW_VAL.value.format(register=SEQUENCE_TABLE.register, 352 table_name=SEQUENCE_TABLE.name, 353 row=row_num, 354 col_name=RegisterFlag.NUM_INJ, 355 val=row.num_inj)) 356 357 if row.inj_vol: 358 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 359 table_name=SEQUENCE_TABLE.name, 360 row=row_num, 361 col_name=RegisterFlag.INJ_VOL, 362 val=row.inj_vol)) 363 364 if row.inj_source: 365 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 366 table_name=SEQUENCE_TABLE.name, 367 row=row_num, 368 col_name=RegisterFlag.INJ_SOR, 369 val=row.inj_source)) 370 371 if row.sample_name: 372 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 373 table_name=SEQUENCE_TABLE.name, 374 row=row_num, 375 col_name=RegisterFlag.NAME, 376 val=row.sample_name)) 377 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 378 table_name=SEQUENCE_TABLE.name, 379 row=row_num, 380 col_name=RegisterFlag.DATA_FILE, 381 val=row.sample_name)) 382 if row.data_file: 383 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 384 table_name=SEQUENCE_TABLE.name, 385 row=row_num, 386 col_name=RegisterFlag.DATA_FILE, 387 val=row.sample_name)) 388 389 if row.sample_type: 390 self.sleepy_send(TableOperation.EDIT_ROW_VAL.value.format(register=SEQUENCE_TABLE.register, 391 table_name=SEQUENCE_TABLE.name, 392 row=row_num, 393 col_name=RegisterFlag.SAMPLE_TYPE, 394 val=row.sample_type)) 395 396 def edit_method(self, updated_method: MethodTimetable): 397 """Updated the currently loaded method in ChemStation with provided values. 398 399 :param updated_method: the method with updated values, to be sent to Chemstation to modify the currently loaded method. 400 """ 401 initial_organic_modifier: Param = updated_method.first_row.organic_modifier 402 max_time: Param = updated_method.first_row.maximum_run_time 403 temp: Param = updated_method.first_row.temperature 404 injvol: Param = updated_method.first_row.inj_vol 405 equalib_time: Param = updated_method.first_row.equ_time 406 flow: Param = updated_method.first_row.flow 407 408 # Method settings required for all runs 409 self.send(TableOperation.DELETE_TABLE.value.format(register=METHOD_TIMETABLE.register, 410 table_name=METHOD_TIMETABLE.name, )) 411 self._update_method_param(initial_organic_modifier) 412 self._update_method_param(flow) 413 self._update_method_param(Param(val="Set", 414 chemstation_key=RegisterFlag.STOPTIME_MODE, 415 ptype=PType.STR)) 416 self._update_method_param(max_time) 417 self._update_method_param(Param(val="Off", 418 chemstation_key=RegisterFlag.POSTIME_MODE, 419 ptype=PType.STR)) 420 421 self.send("DownloadRCMethod PMP1") 422 423 self._update_method_timetable(updated_method.subsequent_rows) 424 425 def _update_method_timetable(self, timetable_rows: list[Entry]): 426 """Updates the timetable, which is seen when right-clicking the pump GUI in Chemstation to get to the timetable. 427 428 :param timetable_rows: 429 """ 430 self.sleepy_send('Local Rows') 431 self._get_table_rows(METHOD_TIMETABLE) 432 433 self.sleepy_send('DelTab RCPMP1Method[1], "Timetable"') 434 res = self._get_table_rows(METHOD_TIMETABLE) 435 while "ERROR" not in res: 436 self.sleepy_send('DelTab RCPMP1Method[1], "Timetable"') 437 res = self._get_table_rows(METHOD_TIMETABLE) 438 439 self.sleepy_send('NewTab RCPMP1Method[1], "Timetable"') 440 self._get_table_rows(METHOD_TIMETABLE) 441 442 for i, row in enumerate(timetable_rows): 443 if i == 0: 444 self.send('Sleep 1') 445 self.sleepy_send('InsTabRow RCPMP1Method[1], "Timetable"') 446 self.send('Sleep 1') 447 448 self.sleepy_send('NewColText RCPMP1Method[1], "Timetable", "Function", "SolventComposition"') 449 self.sleepy_send(f'NewColVal RCPMP1Method[1], "Timetable", "Time", {row.start_time}') 450 self.sleepy_send( 451 f'NewColVal RCPMP1Method[1], "Timetable", "SolventCompositionPumpChannel2_Percentage", {row.organic_modifer}') 452 453 self.send('Sleep 1') 454 self.sleepy_send("DownloadRCMethod PMP1") 455 self.send('Sleep 1') 456 else: 457 self.sleepy_send('InsTabRow RCPMP1Method[1], "Timetable"') 458 self._get_table_rows(METHOD_TIMETABLE) 459 460 self.sleepy_send( 461 f'SetTabText RCPMP1Method[1], "Timetable", Rows, "Function", "SolventComposition"') 462 self.sleepy_send( 463 f'SetTabVal RCPMP1Method[1], "Timetable", Rows, "Time", {row.start_time}') 464 self.sleepy_send( 465 f'SetTabVal RCPMP1Method[1], "Timetable", Rows, "SolventCompositionPumpChannel2_Percentage", {row.organic_modifer}') 466 467 self.send("Sleep 1") 468 self.sleepy_send("DownloadRCMethod PMP1") 469 self.send("Sleep 1") 470 self._get_table_rows(METHOD_TIMETABLE) 471 472 def _update_method_param(self, method_param: Param): 473 """Change a method parameter, changes what is visibly seen in Chemstation GUI. (changes the first row in the timetable) 474 475 :param method_param: a parameter to update for currently loaded method. 476 """ 477 register = METHOD_TIMETABLE.register 478 setting_command = TableOperation.UPDATE_OBJ_HDR_VAL if method_param.ptype == PType.NUM else TableOperation.UPDATE_OBJ_HDR_TEXT 479 if isinstance(method_param.chemstation_key, list): 480 for register_flag in method_param.chemstation_key: 481 self.send(setting_command.value.format(register=register, 482 register_flag=register_flag, 483 val=method_param.val)) 484 else: 485 self.send(setting_command.value.format(register=register, 486 register_flag=method_param.chemstation_key, 487 val=method_param.val)) 488 time.sleep(2) 489 490 def desired_method_already_loaded(self, method_name: str) -> bool: 491 """Checks if a given method is already loaded into Chemstation. Method name does not need the ".M" extension. 492 493 :param method_name: a Chemstation method 494 :return: True if method is already loaded 495 """ 496 self.send(Command.GET_METHOD_CMD) 497 parsed_response = self.receive().splitlines()[1].split()[1:][0] 498 return method_name in parsed_response 499 500 def switch_method(self, method_name: str): 501 """Allows the user to switch between pre-programmed methods. No need to append '.M' 502 to the end of the method name. For example. for the method named 'General-Poroshell.M', 503 only 'General-Poroshell' is needed. 504 505 :param method_name: any available method in Chemstation method directory 506 :raise IndexError: Response did not have expected format. Try again. 507 :raise AssertionError: The desired method is not selected. Try again. 508 """ 509 self.send( 510 Command.SWITCH_METHOD_CMD.value.format(method_dir=self.method_dir, method_name=method_name) 511 ) 512 513 time.sleep(2) 514 self.send(Command.GET_METHOD_CMD) 515 time.sleep(2) 516 # check that method switched 517 for _ in range(10): 518 try: 519 parsed_response = self.receive().splitlines()[1].split()[1:][0] 520 break 521 except IndexError: 522 self.logger.debug("Malformed response. Trying again.") 523 continue 524 525 assert parsed_response == f"{method_name}.M", "Switching Methods failed." 526 527 def load_method_details(self, method_name: str) -> MethodTimetable: 528 """Retrieve method details of an existing method. Don't need to append ".M" to the end. This assumes the 529 organic modifier is in Channel B and that Channel A contains the aq layer. Additionally, assumes 530 only two solvents are being used. 531 532 :param method_name: name of method to load details of 533 :raises FileNotFoundError: Method does not exist 534 :return: method details 535 """ 536 method_folder = f"{method_name}.M" 537 method_path = os.path.join(self.method_dir, method_folder, "AgilentPumpDriver1.RapidControl.MethodXML.xml") 538 dad_path = os.path.join(self.method_dir, method_folder, "Agilent1200erDadDriver1.RapidControl.MethodXML.xml") 539 540 if os.path.exists(os.path.join(self.method_dir, f"{method_name}.M")): 541 parser = XmlParser() 542 method = parser.parse(method_path, PumpMethod) 543 dad = parser.parse(dad_path, DadMethod) 544 545 organic_modifier = None 546 aq_modifier = None 547 548 if len(method.solvent_composition.solvent_element) == 2: 549 for solvent in method.solvent_composition.solvent_element: 550 if solvent.channel == "Channel_A": 551 aq_modifier = solvent 552 elif solvent.channel == "Channel_B": 553 organic_modifier = solvent 554 555 return MethodTimetable( 556 first_row=HPLCMethodParams( 557 organic_modifier=Param(val=organic_modifier.percentage, 558 chemstation_key=RegisterFlag.SOLVENT_B_COMPOSITION, 559 ptype=PType.NUM), 560 flow=Param(val=method.flow, 561 chemstation_key=RegisterFlag.FLOW, 562 ptype=PType.NUM), 563 maximum_run_time=Param(val=method.stop_time, 564 chemstation_key=RegisterFlag.MAX_TIME, 565 ptype=PType.NUM), 566 temperature=Param(val=None, 567 chemstation_key=[RegisterFlag.COLUMN_OVEN_TEMP1, 568 RegisterFlag.COLUMN_OVEN_TEMP2], 569 ptype=PType.NUM), 570 inj_vol=Param(val=None, 571 chemstation_key=None, 572 ptype=PType.NUM), 573 equ_time=Param(val=None, 574 chemstation_key=None, 575 ptype=PType.NUM)), 576 subsequent_rows=[ 577 Entry( 578 start_time=tte.time, 579 organic_modifer=tte.percent_b, 580 flow=method.flow 581 ) for tte in method.timetable.timetable_entry 582 ], 583 dad_wavelengthes=dad.signals.signal, 584 organic_modifier=organic_modifier, 585 modifier_a=aq_modifier 586 ) 587 else: 588 raise FileNotFoundError 589 590 def lamp_on(self): 591 """Turns the UV lamp on.""" 592 self.send(Command.LAMP_ON_CMD) 593 594 def lamp_off(self): 595 """Turns the UV lamp off.""" 596 self.send(Command.LAMP_OFF_CMD) 597 598 def pump_on(self): 599 """Turns on the pump on.""" 600 self.send(Command.PUMP_ON_CMD) 601 602 def pump_off(self): 603 """Turns the pump off.""" 604 self.send(Command.PUMP_OFF_CMD) 605 606 def switch_sequence(self, seq_name: str): 607 """Switch to the specified sequence. The sequence name does not need the '.S' extension. 608 :param seq_name: The name of the sequence file 609 :param sequence_dir: The directory where the sequence file resides 610 """ 611 self.send(f'_SeqFile$ = "{seq_name}.S"') 612 self.send(f'_SeqPath$ = "{self.sequence_dir}"') 613 self.send(Command.SWITCH_SEQUENCE_CMD) 614 615 time.sleep(2) 616 self.send(Command.GET_SEQUENCE_CMD) 617 time.sleep(2) 618 # check that method switched 619 for _ in range(10): 620 try: 621 parsed_response = self.receive().splitlines()[1].split()[1:][0] 622 break 623 except IndexError: 624 self.logger.debug("Malformed response. Trying again.") 625 continue 626 627 assert parsed_response == f"{seq_name}.S", "Switching sequence failed." 628 629 def run_sequence(self, sequence_table: SequenceTable): 630 """Starts the currently loaded sequence, storing data 631 under the <data_dir>/<sequence table name> folder. 632 The <sequence table name> will be appended with a timestamp in the "%Y %m %d %H %m %s" format. 633 Device must be ready. 634 635 :param sequence_table: 636 """ 637 timestamp = time.strftime(SEQUENCE_TIME_FORMAT) 638 639 self.send(Command.RUN_SEQUENCE_CMD.value) 640 641 folder_name = f"{sequence_table.name} {timestamp}" 642 self.data_files.append(os.path.join(self.data_dir, folder_name)) 643 self.logger.info("Started HPLC sequence: %s.", folder_name) 644 645 subdirs = [x[0] for x in os.walk(self.data_dir)] 646 647 time.sleep(60) 648 649 potential_folders = sorted(list(filter(lambda d: folder_name in d, subdirs))) 650 651 print(potential_folders) 652 653 def start_method(self): 654 """Starts and executes currently loaded method to run according to Run Time Checklist. Device must be ready. 655 Does not store the folder where data is saved. 656 """ 657 self.send(Command.START_METHOD_CMD) 658 659 def run_method(self, experiment_name: str): 660 """This is the preferred method to trigger a run. 661 Starts the currently selected method, storing data 662 under the <data_dir>/<experiment_name>.D folder. 663 The <experiment_name> will be appended with a timestamp in the '%Y-%m-%d-%H-%M' format. 664 Device must be ready. 665 666 :param experiment_name: Name of the experiment 667 """ 668 timestamp = time.strftime(TIME_FORMAT) 669 670 self.send( 671 Command.RUN_METHOD_CMD.value.format( 672 data_dir=self.data_dir, experiment_name=experiment_name, timestamp=timestamp 673 ) 674 ) 675 676 folder_name = f"{experiment_name}_{timestamp}.D" 677 self.data_files.append(os.path.join(self.data_dir, folder_name)) 678 self.logger.info("Started HPLC run: %s.", folder_name) 679 680 def stop_method(self): 681 """Stops the run. A dialog window will pop up and manual intervention may be required.""" 682 self.send(Command.STOP_METHOD_CMD) 683 684 def get_spectrum(self): 685 """ Load last chromatogram for any channel in spectra dictionary.""" 686 last_file = self.data_files[-1] if len(self.data_files) > 0 else None 687 688 if last_file is None: 689 raise IndexError 690 691 for channel, spec in self.spectra.items(): 692 spec.load_spectrum(data_path=last_file, channel=channel) 693 self.logger.info("%s chromatogram loaded.", channel)
30class HPLCController: 31 """ 32 Class to control Agilent HPLC systems via Chemstation Macros. 33 """ 34 35 def __init__( 36 self, 37 comm_dir: str, 38 data_dir: str, 39 method_dir: str, 40 sequence_dir: str, 41 cmd_file: str = "cmd", 42 reply_file: str = "reply", 43 ): 44 """Initialize HPLC controller. The `hplc_talk.mac` macro file must be loaded in the Chemstation software. 45 `comm_dir` must match the file path in the macro file. 46 47 :param comm_dir: Name of directory for communication, where ChemStation will read and write from. Can be any existing directory. 48 :param data_dir: Name of directory that ChemStation saves run data. Must be accessible by ChemStation. 49 :param cmd_file: Name of command file 50 :param reply_file: Name of reply file 51 :raises FileNotFoundError: If either `data_dir`, `method_dir` or `comm_dir` is not a valid directory. 52 """ 53 if os.path.isdir(comm_dir): 54 self.cmd_file = os.path.join(comm_dir, cmd_file) 55 self.reply_file = os.path.join(comm_dir, reply_file) 56 self.cmd_no = 0 57 else: 58 raise FileNotFoundError(f"comm_dir: {comm_dir} not found.") 59 self._most_recent_hplc_status = None 60 61 if os.path.isdir(data_dir): 62 self.data_dir = data_dir 63 else: 64 raise FileNotFoundError(f"data_dir: {data_dir} not found.") 65 66 if os.path.isdir(method_dir): 67 self.method_dir = method_dir 68 else: 69 raise FileNotFoundError(f"method_dir: {method_dir} not found.") 70 71 if os.path.isdir(sequence_dir): 72 self.sequence_dir = sequence_dir 73 else: 74 raise FileNotFoundError(f"method_dir: {method_dir} not found.") 75 76 self.spectra = { 77 "A": AgilentHPLCChromatogram(self.data_dir), 78 "B": AgilentHPLCChromatogram(self.data_dir), 79 "C": AgilentHPLCChromatogram(self.data_dir), 80 "D": AgilentHPLCChromatogram(self.data_dir), 81 } 82 83 self.data_files: list[str] = [] 84 self.internal_variables: list[dict[str, str]] = [] 85 86 # Create files for Chemstation to communicate with Python 87 open(self.cmd_file, "a").close() 88 open(self.reply_file, "a").close() 89 90 self.logger = logging.getLogger("hplc_logger") 91 self.logger.addHandler(logging.NullHandler()) 92 93 self.reset_cmd_counter() 94 95 self.logger.info("HPLC Controller initialized.") 96 97 def _set_status(self): 98 """Updates current status of HPLC machine""" 99 self._most_recent_hplc_status = self.status()[0] 100 101 def _check_data_status(self) -> bool: 102 """Checks if HPLC machine is in an available state, meaning a state that data is not being written. 103 104 :return: whether the HPLC machine is in a safe state to retrieve data back.""" 105 old_status = self._most_recent_hplc_status 106 self._set_status() 107 file_exists = os.path.exists(self.data_files[-1]) if len(self.data_files) > 0 else False 108 done_writing_data = isinstance(self._most_recent_hplc_status, 109 HPLCAvailStatus) and old_status != self._most_recent_hplc_status and file_exists 110 return done_writing_data 111 112 def check_hplc_ready_with_data(self) -> bool: 113 """Checks if ChemStation has finished writing data and can be read back. 114 115 :param method: if you are running a method and want to read back data, the timeout period will be adjusted to be longer than the method's runtime 116 117 :return: Return True if data can be read back, else False. 118 """ 119 self._set_status() 120 121 timeout = 10 * 60 122 hplc_run_done = polling.poll( 123 lambda: self._check_data_status(), 124 timeout=timeout, 125 step=30 126 ) 127 128 return hplc_run_done 129 130 def _send(self, cmd: str, cmd_no: int, num_attempts=5) -> None: 131 """Low-level execution primitive. Sends a command string to HPLC. 132 133 :param cmd: string to be sent to HPLC 134 :param cmd_no: Command number 135 :param num_attempts: Number of attempts to send the command before raising exception. 136 :raises IOError: Could not write to command file. 137 """ 138 err = None 139 for _ in range(num_attempts): 140 time.sleep(1) 141 try: 142 with open(self.cmd_file, "w", encoding="utf8") as cmd_file: 143 cmd_file.write(f"{cmd_no} {cmd}") 144 except IOError as e: 145 err = e 146 self.logger.warning("Failed to send command; trying again.") 147 continue 148 else: 149 self.logger.info("Sent command #%d: %s.", cmd_no, cmd) 150 return 151 else: 152 raise IOError(f"Failed to send command #{cmd_no}: {cmd}.") from err 153 154 def _receive(self, cmd_no: int, num_attempts=100) -> str: 155 """Low-level execution primitive. Recives a response from HPLC. 156 157 :param cmd_no: Command number 158 :param num_attempts: Number of retries to open reply file 159 :raises IOError: Could not read reply file. 160 :return: ChemStation response 161 """ 162 err = None 163 for _ in range(num_attempts): 164 time.sleep(1) 165 166 try: 167 with open(self.reply_file, "r", encoding="utf_16") as reply_file: 168 response = reply_file.read() 169 except OSError as e: 170 err = e 171 self.logger.warning("Failed to read from reply file; trying again.") 172 continue 173 174 try: 175 first_line = response.splitlines()[0] 176 response_no = int(first_line.split()[0]) 177 except IndexError as e: 178 err = e 179 self.logger.warning("Malformed response %s; trying again.", response) 180 continue 181 182 # check that response corresponds to sent command 183 if response_no == cmd_no: 184 self.logger.info("Reply: \n%s", response) 185 return response 186 else: 187 self.logger.warning( 188 "Response #: %d != command #: %d; trying again.", 189 response_no, 190 cmd_no, 191 ) 192 continue 193 else: 194 raise IOError(f"Failed to receive reply to command #{cmd_no}.") from err 195 196 def sleepy_send(self, cmd: Union[Command, str]): 197 self.send("Sleep 0.1") 198 self.send(cmd) 199 self.send("Sleep 0.1") 200 201 def send(self, cmd: Union[Command, str]): 202 """Sends a command to Chemstation. 203 204 :param cmd: Command to be sent to HPLC 205 """ 206 if self.cmd_no == MAX_CMD_NO: 207 self.reset_cmd_counter() 208 209 cmd_to_send: str = cmd.value if isinstance(cmd, Command) else cmd 210 self.cmd_no += 1 211 self._send(cmd_to_send, self.cmd_no) 212 213 def receive(self) -> str: 214 """Returns messages received in reply file. 215 216 :return: ChemStation response 217 """ 218 return self._receive(self.cmd_no) 219 220 def reset_cmd_counter(self): 221 """Resets the command counter.""" 222 self._send(Command.RESET_COUNTER_CMD.value, cmd_no=MAX_CMD_NO + 1) 223 self._receive(cmd_no=MAX_CMD_NO + 1) 224 self.cmd_no = 0 225 226 self.logger.debug("Reset command counter") 227 228 def sleep(self, seconds: int): 229 """Tells the HPLC to wait for a specified number of seconds. 230 231 :param seconds: number of seconds to wait 232 """ 233 self.send(Command.SLEEP_CMD.value.format(seconds=seconds)) 234 self.logger.debug("Sleep command sent.") 235 236 def standby(self): 237 """Switches all modules in standby mode. All lamps and pumps are switched off.""" 238 self.send(Command.STANDBY_CMD) 239 self.logger.debug("Standby command sent.") 240 241 def preprun(self): 242 """ Prepares all modules for run. All lamps and pumps are switched on.""" 243 self.send(Command.PREPRUN_CMD) 244 self.logger.debug("PrepRun command sent.") 245 246 def status(self) -> list[Union[HPLCRunningStatus, HPLCAvailStatus, HPLCErrorStatus]]: 247 """Get device status(es). 248 249 :return: list of ChemStation's current status 250 """ 251 self.send(Command.GET_STATUS_CMD) 252 time.sleep(1) 253 254 try: 255 parsed_response = self.receive().splitlines()[1].split()[1:] 256 except IOError: 257 return [HPLCErrorStatus.NORESPONSE] 258 except IndexError: 259 return [HPLCErrorStatus.MALFORMED] 260 recieved_status = [str_to_status(res) for res in parsed_response] 261 self._most_recent_hplc_status = recieved_status[0] 262 return recieved_status 263 264 def stop_macro(self): 265 """Stops Macro execution. Connection will be lost.""" 266 self.send(Command.STOP_MACRO_CMD) 267 268 def add_table_row(self, table: Table): 269 """Adds a row to the provided table for currently loaded method or sequence. 270 Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants. 271 You can also provide your own table. 272 273 :param table: the table to add a new row to 274 """ 275 self.sleepy_send(TableOperation.NEW_ROW.value.format(register=table.register, table_name=table.name)) 276 277 def delete_table(self, table: Table): 278 """Deletes the table for the current loaded method or sequence. 279 Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants. 280 You can also provide your own table. 281 282 :param table: the table to delete 283 """ 284 self.sleepy_send(TableOperation.DELETE_TABLE.value.format(register=table.register, table_name=table.name)) 285 286 def new_table(self, table: Table): 287 """Creates the table for the currently loaded method or sequence. 288 Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants. 289 You can also provide your own table. 290 291 :param table: the table to create 292 """ 293 self.send(TableOperation.CREATE_TABLE.value.format(register=table.register, table_name=table.name)) 294 295 def _get_table_rows(self, table: Table) -> str: 296 self.send(TableOperation.GET_OBJ_HDR_VAL.value.format(internal_val="Rows", 297 register=table.register, 298 table_name=table.name, 299 col_name=RegisterFlag.NUM_ROWS, )) 300 res = self.receive() 301 self.send("Sleep 1") 302 self.send('Print Rows') 303 return res 304 305 def edit_sequence_table(self, sequence_table: SequenceTable): 306 """Updates the currently loaded sequence table with the provided table. This method will delete the existing sequence table and remake it. 307 If you would only like to edit a single row of a sequence table, use `edit_sequence_table_row` instead. 308 309 :param sequence_table: 310 """ 311 self.send("Local Rows") 312 self.sleep(1) 313 self.delete_table(SEQUENCE_TABLE) 314 self.sleep(1) 315 self.new_table(SEQUENCE_TABLE) 316 self.sleep(1) 317 self._get_table_rows(SEQUENCE_TABLE) 318 for _ in sequence_table.rows: 319 self.add_table_row(SEQUENCE_TABLE) 320 self.sleep(1) 321 self.send(Command.SAVE_SEQUENCE_CMD) 322 self.sleep(1) 323 self._get_table_rows(SEQUENCE_TABLE) 324 self.send(Command.SWITCH_SEQUENCE_CMD) 325 for i, row in enumerate(sequence_table.rows): 326 self.edit_sequence_table_row(row=row, row_num=i + 1) 327 self.sleep(1) 328 self.send(Command.SAVE_SEQUENCE_CMD) 329 self.sleep(1) 330 331 def edit_sequence_table_row(self, row: SequenceEntry, row_num: int): 332 """Edits a row in the sequence table. Assumes the row already exists. 333 334 :param row: sequence row entry with updated information 335 :param row_num: the row to edit, based on 1-based indexing 336 """ 337 338 if row.vial_location: 339 self.sleepy_send(TableOperation.EDIT_ROW_VAL.value.format(register=SEQUENCE_TABLE.register, 340 table_name=SEQUENCE_TABLE.name, 341 row=row_num, 342 col_name=RegisterFlag.VIAL_LOCATION, 343 val=row.vial_location)) 344 if row.method: 345 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 346 table_name=SEQUENCE_TABLE.name, 347 row=row_num, 348 col_name=RegisterFlag.METHOD, 349 val=row.method)) 350 351 if row.num_inj: 352 self.sleepy_send(TableOperation.EDIT_ROW_VAL.value.format(register=SEQUENCE_TABLE.register, 353 table_name=SEQUENCE_TABLE.name, 354 row=row_num, 355 col_name=RegisterFlag.NUM_INJ, 356 val=row.num_inj)) 357 358 if row.inj_vol: 359 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 360 table_name=SEQUENCE_TABLE.name, 361 row=row_num, 362 col_name=RegisterFlag.INJ_VOL, 363 val=row.inj_vol)) 364 365 if row.inj_source: 366 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 367 table_name=SEQUENCE_TABLE.name, 368 row=row_num, 369 col_name=RegisterFlag.INJ_SOR, 370 val=row.inj_source)) 371 372 if row.sample_name: 373 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 374 table_name=SEQUENCE_TABLE.name, 375 row=row_num, 376 col_name=RegisterFlag.NAME, 377 val=row.sample_name)) 378 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 379 table_name=SEQUENCE_TABLE.name, 380 row=row_num, 381 col_name=RegisterFlag.DATA_FILE, 382 val=row.sample_name)) 383 if row.data_file: 384 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 385 table_name=SEQUENCE_TABLE.name, 386 row=row_num, 387 col_name=RegisterFlag.DATA_FILE, 388 val=row.sample_name)) 389 390 if row.sample_type: 391 self.sleepy_send(TableOperation.EDIT_ROW_VAL.value.format(register=SEQUENCE_TABLE.register, 392 table_name=SEQUENCE_TABLE.name, 393 row=row_num, 394 col_name=RegisterFlag.SAMPLE_TYPE, 395 val=row.sample_type)) 396 397 def edit_method(self, updated_method: MethodTimetable): 398 """Updated the currently loaded method in ChemStation with provided values. 399 400 :param updated_method: the method with updated values, to be sent to Chemstation to modify the currently loaded method. 401 """ 402 initial_organic_modifier: Param = updated_method.first_row.organic_modifier 403 max_time: Param = updated_method.first_row.maximum_run_time 404 temp: Param = updated_method.first_row.temperature 405 injvol: Param = updated_method.first_row.inj_vol 406 equalib_time: Param = updated_method.first_row.equ_time 407 flow: Param = updated_method.first_row.flow 408 409 # Method settings required for all runs 410 self.send(TableOperation.DELETE_TABLE.value.format(register=METHOD_TIMETABLE.register, 411 table_name=METHOD_TIMETABLE.name, )) 412 self._update_method_param(initial_organic_modifier) 413 self._update_method_param(flow) 414 self._update_method_param(Param(val="Set", 415 chemstation_key=RegisterFlag.STOPTIME_MODE, 416 ptype=PType.STR)) 417 self._update_method_param(max_time) 418 self._update_method_param(Param(val="Off", 419 chemstation_key=RegisterFlag.POSTIME_MODE, 420 ptype=PType.STR)) 421 422 self.send("DownloadRCMethod PMP1") 423 424 self._update_method_timetable(updated_method.subsequent_rows) 425 426 def _update_method_timetable(self, timetable_rows: list[Entry]): 427 """Updates the timetable, which is seen when right-clicking the pump GUI in Chemstation to get to the timetable. 428 429 :param timetable_rows: 430 """ 431 self.sleepy_send('Local Rows') 432 self._get_table_rows(METHOD_TIMETABLE) 433 434 self.sleepy_send('DelTab RCPMP1Method[1], "Timetable"') 435 res = self._get_table_rows(METHOD_TIMETABLE) 436 while "ERROR" not in res: 437 self.sleepy_send('DelTab RCPMP1Method[1], "Timetable"') 438 res = self._get_table_rows(METHOD_TIMETABLE) 439 440 self.sleepy_send('NewTab RCPMP1Method[1], "Timetable"') 441 self._get_table_rows(METHOD_TIMETABLE) 442 443 for i, row in enumerate(timetable_rows): 444 if i == 0: 445 self.send('Sleep 1') 446 self.sleepy_send('InsTabRow RCPMP1Method[1], "Timetable"') 447 self.send('Sleep 1') 448 449 self.sleepy_send('NewColText RCPMP1Method[1], "Timetable", "Function", "SolventComposition"') 450 self.sleepy_send(f'NewColVal RCPMP1Method[1], "Timetable", "Time", {row.start_time}') 451 self.sleepy_send( 452 f'NewColVal RCPMP1Method[1], "Timetable", "SolventCompositionPumpChannel2_Percentage", {row.organic_modifer}') 453 454 self.send('Sleep 1') 455 self.sleepy_send("DownloadRCMethod PMP1") 456 self.send('Sleep 1') 457 else: 458 self.sleepy_send('InsTabRow RCPMP1Method[1], "Timetable"') 459 self._get_table_rows(METHOD_TIMETABLE) 460 461 self.sleepy_send( 462 f'SetTabText RCPMP1Method[1], "Timetable", Rows, "Function", "SolventComposition"') 463 self.sleepy_send( 464 f'SetTabVal RCPMP1Method[1], "Timetable", Rows, "Time", {row.start_time}') 465 self.sleepy_send( 466 f'SetTabVal RCPMP1Method[1], "Timetable", Rows, "SolventCompositionPumpChannel2_Percentage", {row.organic_modifer}') 467 468 self.send("Sleep 1") 469 self.sleepy_send("DownloadRCMethod PMP1") 470 self.send("Sleep 1") 471 self._get_table_rows(METHOD_TIMETABLE) 472 473 def _update_method_param(self, method_param: Param): 474 """Change a method parameter, changes what is visibly seen in Chemstation GUI. (changes the first row in the timetable) 475 476 :param method_param: a parameter to update for currently loaded method. 477 """ 478 register = METHOD_TIMETABLE.register 479 setting_command = TableOperation.UPDATE_OBJ_HDR_VAL if method_param.ptype == PType.NUM else TableOperation.UPDATE_OBJ_HDR_TEXT 480 if isinstance(method_param.chemstation_key, list): 481 for register_flag in method_param.chemstation_key: 482 self.send(setting_command.value.format(register=register, 483 register_flag=register_flag, 484 val=method_param.val)) 485 else: 486 self.send(setting_command.value.format(register=register, 487 register_flag=method_param.chemstation_key, 488 val=method_param.val)) 489 time.sleep(2) 490 491 def desired_method_already_loaded(self, method_name: str) -> bool: 492 """Checks if a given method is already loaded into Chemstation. Method name does not need the ".M" extension. 493 494 :param method_name: a Chemstation method 495 :return: True if method is already loaded 496 """ 497 self.send(Command.GET_METHOD_CMD) 498 parsed_response = self.receive().splitlines()[1].split()[1:][0] 499 return method_name in parsed_response 500 501 def switch_method(self, method_name: str): 502 """Allows the user to switch between pre-programmed methods. No need to append '.M' 503 to the end of the method name. For example. for the method named 'General-Poroshell.M', 504 only 'General-Poroshell' is needed. 505 506 :param method_name: any available method in Chemstation method directory 507 :raise IndexError: Response did not have expected format. Try again. 508 :raise AssertionError: The desired method is not selected. Try again. 509 """ 510 self.send( 511 Command.SWITCH_METHOD_CMD.value.format(method_dir=self.method_dir, method_name=method_name) 512 ) 513 514 time.sleep(2) 515 self.send(Command.GET_METHOD_CMD) 516 time.sleep(2) 517 # check that method switched 518 for _ in range(10): 519 try: 520 parsed_response = self.receive().splitlines()[1].split()[1:][0] 521 break 522 except IndexError: 523 self.logger.debug("Malformed response. Trying again.") 524 continue 525 526 assert parsed_response == f"{method_name}.M", "Switching Methods failed." 527 528 def load_method_details(self, method_name: str) -> MethodTimetable: 529 """Retrieve method details of an existing method. Don't need to append ".M" to the end. This assumes the 530 organic modifier is in Channel B and that Channel A contains the aq layer. Additionally, assumes 531 only two solvents are being used. 532 533 :param method_name: name of method to load details of 534 :raises FileNotFoundError: Method does not exist 535 :return: method details 536 """ 537 method_folder = f"{method_name}.M" 538 method_path = os.path.join(self.method_dir, method_folder, "AgilentPumpDriver1.RapidControl.MethodXML.xml") 539 dad_path = os.path.join(self.method_dir, method_folder, "Agilent1200erDadDriver1.RapidControl.MethodXML.xml") 540 541 if os.path.exists(os.path.join(self.method_dir, f"{method_name}.M")): 542 parser = XmlParser() 543 method = parser.parse(method_path, PumpMethod) 544 dad = parser.parse(dad_path, DadMethod) 545 546 organic_modifier = None 547 aq_modifier = None 548 549 if len(method.solvent_composition.solvent_element) == 2: 550 for solvent in method.solvent_composition.solvent_element: 551 if solvent.channel == "Channel_A": 552 aq_modifier = solvent 553 elif solvent.channel == "Channel_B": 554 organic_modifier = solvent 555 556 return MethodTimetable( 557 first_row=HPLCMethodParams( 558 organic_modifier=Param(val=organic_modifier.percentage, 559 chemstation_key=RegisterFlag.SOLVENT_B_COMPOSITION, 560 ptype=PType.NUM), 561 flow=Param(val=method.flow, 562 chemstation_key=RegisterFlag.FLOW, 563 ptype=PType.NUM), 564 maximum_run_time=Param(val=method.stop_time, 565 chemstation_key=RegisterFlag.MAX_TIME, 566 ptype=PType.NUM), 567 temperature=Param(val=None, 568 chemstation_key=[RegisterFlag.COLUMN_OVEN_TEMP1, 569 RegisterFlag.COLUMN_OVEN_TEMP2], 570 ptype=PType.NUM), 571 inj_vol=Param(val=None, 572 chemstation_key=None, 573 ptype=PType.NUM), 574 equ_time=Param(val=None, 575 chemstation_key=None, 576 ptype=PType.NUM)), 577 subsequent_rows=[ 578 Entry( 579 start_time=tte.time, 580 organic_modifer=tte.percent_b, 581 flow=method.flow 582 ) for tte in method.timetable.timetable_entry 583 ], 584 dad_wavelengthes=dad.signals.signal, 585 organic_modifier=organic_modifier, 586 modifier_a=aq_modifier 587 ) 588 else: 589 raise FileNotFoundError 590 591 def lamp_on(self): 592 """Turns the UV lamp on.""" 593 self.send(Command.LAMP_ON_CMD) 594 595 def lamp_off(self): 596 """Turns the UV lamp off.""" 597 self.send(Command.LAMP_OFF_CMD) 598 599 def pump_on(self): 600 """Turns on the pump on.""" 601 self.send(Command.PUMP_ON_CMD) 602 603 def pump_off(self): 604 """Turns the pump off.""" 605 self.send(Command.PUMP_OFF_CMD) 606 607 def switch_sequence(self, seq_name: str): 608 """Switch to the specified sequence. The sequence name does not need the '.S' extension. 609 :param seq_name: The name of the sequence file 610 :param sequence_dir: The directory where the sequence file resides 611 """ 612 self.send(f'_SeqFile$ = "{seq_name}.S"') 613 self.send(f'_SeqPath$ = "{self.sequence_dir}"') 614 self.send(Command.SWITCH_SEQUENCE_CMD) 615 616 time.sleep(2) 617 self.send(Command.GET_SEQUENCE_CMD) 618 time.sleep(2) 619 # check that method switched 620 for _ in range(10): 621 try: 622 parsed_response = self.receive().splitlines()[1].split()[1:][0] 623 break 624 except IndexError: 625 self.logger.debug("Malformed response. Trying again.") 626 continue 627 628 assert parsed_response == f"{seq_name}.S", "Switching sequence failed." 629 630 def run_sequence(self, sequence_table: SequenceTable): 631 """Starts the currently loaded sequence, storing data 632 under the <data_dir>/<sequence table name> folder. 633 The <sequence table name> will be appended with a timestamp in the "%Y %m %d %H %m %s" format. 634 Device must be ready. 635 636 :param sequence_table: 637 """ 638 timestamp = time.strftime(SEQUENCE_TIME_FORMAT) 639 640 self.send(Command.RUN_SEQUENCE_CMD.value) 641 642 folder_name = f"{sequence_table.name} {timestamp}" 643 self.data_files.append(os.path.join(self.data_dir, folder_name)) 644 self.logger.info("Started HPLC sequence: %s.", folder_name) 645 646 subdirs = [x[0] for x in os.walk(self.data_dir)] 647 648 time.sleep(60) 649 650 potential_folders = sorted(list(filter(lambda d: folder_name in d, subdirs))) 651 652 print(potential_folders) 653 654 def start_method(self): 655 """Starts and executes currently loaded method to run according to Run Time Checklist. Device must be ready. 656 Does not store the folder where data is saved. 657 """ 658 self.send(Command.START_METHOD_CMD) 659 660 def run_method(self, experiment_name: str): 661 """This is the preferred method to trigger a run. 662 Starts the currently selected method, storing data 663 under the <data_dir>/<experiment_name>.D folder. 664 The <experiment_name> will be appended with a timestamp in the '%Y-%m-%d-%H-%M' format. 665 Device must be ready. 666 667 :param experiment_name: Name of the experiment 668 """ 669 timestamp = time.strftime(TIME_FORMAT) 670 671 self.send( 672 Command.RUN_METHOD_CMD.value.format( 673 data_dir=self.data_dir, experiment_name=experiment_name, timestamp=timestamp 674 ) 675 ) 676 677 folder_name = f"{experiment_name}_{timestamp}.D" 678 self.data_files.append(os.path.join(self.data_dir, folder_name)) 679 self.logger.info("Started HPLC run: %s.", folder_name) 680 681 def stop_method(self): 682 """Stops the run. A dialog window will pop up and manual intervention may be required.""" 683 self.send(Command.STOP_METHOD_CMD) 684 685 def get_spectrum(self): 686 """ Load last chromatogram for any channel in spectra dictionary.""" 687 last_file = self.data_files[-1] if len(self.data_files) > 0 else None 688 689 if last_file is None: 690 raise IndexError 691 692 for channel, spec in self.spectra.items(): 693 spec.load_spectrum(data_path=last_file, channel=channel) 694 self.logger.info("%s chromatogram loaded.", channel)
Class to control Agilent HPLC systems via Chemstation Macros.
35 def __init__( 36 self, 37 comm_dir: str, 38 data_dir: str, 39 method_dir: str, 40 sequence_dir: str, 41 cmd_file: str = "cmd", 42 reply_file: str = "reply", 43 ): 44 """Initialize HPLC controller. The `hplc_talk.mac` macro file must be loaded in the Chemstation software. 45 `comm_dir` must match the file path in the macro file. 46 47 :param comm_dir: Name of directory for communication, where ChemStation will read and write from. Can be any existing directory. 48 :param data_dir: Name of directory that ChemStation saves run data. Must be accessible by ChemStation. 49 :param cmd_file: Name of command file 50 :param reply_file: Name of reply file 51 :raises FileNotFoundError: If either `data_dir`, `method_dir` or `comm_dir` is not a valid directory. 52 """ 53 if os.path.isdir(comm_dir): 54 self.cmd_file = os.path.join(comm_dir, cmd_file) 55 self.reply_file = os.path.join(comm_dir, reply_file) 56 self.cmd_no = 0 57 else: 58 raise FileNotFoundError(f"comm_dir: {comm_dir} not found.") 59 self._most_recent_hplc_status = None 60 61 if os.path.isdir(data_dir): 62 self.data_dir = data_dir 63 else: 64 raise FileNotFoundError(f"data_dir: {data_dir} not found.") 65 66 if os.path.isdir(method_dir): 67 self.method_dir = method_dir 68 else: 69 raise FileNotFoundError(f"method_dir: {method_dir} not found.") 70 71 if os.path.isdir(sequence_dir): 72 self.sequence_dir = sequence_dir 73 else: 74 raise FileNotFoundError(f"method_dir: {method_dir} not found.") 75 76 self.spectra = { 77 "A": AgilentHPLCChromatogram(self.data_dir), 78 "B": AgilentHPLCChromatogram(self.data_dir), 79 "C": AgilentHPLCChromatogram(self.data_dir), 80 "D": AgilentHPLCChromatogram(self.data_dir), 81 } 82 83 self.data_files: list[str] = [] 84 self.internal_variables: list[dict[str, str]] = [] 85 86 # Create files for Chemstation to communicate with Python 87 open(self.cmd_file, "a").close() 88 open(self.reply_file, "a").close() 89 90 self.logger = logging.getLogger("hplc_logger") 91 self.logger.addHandler(logging.NullHandler()) 92 93 self.reset_cmd_counter() 94 95 self.logger.info("HPLC Controller initialized.")
Initialize HPLC controller. The hplc_talk.mac
macro file must be loaded in the Chemstation software.
comm_dir
must match the file path in the macro file.
Parameters
- comm_dir: Name of directory for communication, where ChemStation will read and write from. Can be any existing directory.
- data_dir: Name of directory that ChemStation saves run data. Must be accessible by ChemStation.
- cmd_file: Name of command file
- reply_file: Name of reply file
Raises
- FileNotFoundError: If either
data_dir
,method_dir
orcomm_dir
is not a valid directory.
112 def check_hplc_ready_with_data(self) -> bool: 113 """Checks if ChemStation has finished writing data and can be read back. 114 115 :param method: if you are running a method and want to read back data, the timeout period will be adjusted to be longer than the method's runtime 116 117 :return: Return True if data can be read back, else False. 118 """ 119 self._set_status() 120 121 timeout = 10 * 60 122 hplc_run_done = polling.poll( 123 lambda: self._check_data_status(), 124 timeout=timeout, 125 step=30 126 ) 127 128 return hplc_run_done
Checks if ChemStation has finished writing data and can be read back.
Parameters
- method: if you are running a method and want to read back data, the timeout period will be adjusted to be longer than the method's runtime
Returns
Return True if data can be read back, else False.
201 def send(self, cmd: Union[Command, str]): 202 """Sends a command to Chemstation. 203 204 :param cmd: Command to be sent to HPLC 205 """ 206 if self.cmd_no == MAX_CMD_NO: 207 self.reset_cmd_counter() 208 209 cmd_to_send: str = cmd.value if isinstance(cmd, Command) else cmd 210 self.cmd_no += 1 211 self._send(cmd_to_send, self.cmd_no)
Sends a command to Chemstation.
Parameters
- cmd: Command to be sent to HPLC
213 def receive(self) -> str: 214 """Returns messages received in reply file. 215 216 :return: ChemStation response 217 """ 218 return self._receive(self.cmd_no)
Returns messages received in reply file.
Returns
ChemStation response
220 def reset_cmd_counter(self): 221 """Resets the command counter.""" 222 self._send(Command.RESET_COUNTER_CMD.value, cmd_no=MAX_CMD_NO + 1) 223 self._receive(cmd_no=MAX_CMD_NO + 1) 224 self.cmd_no = 0 225 226 self.logger.debug("Reset command counter")
Resets the command counter.
228 def sleep(self, seconds: int): 229 """Tells the HPLC to wait for a specified number of seconds. 230 231 :param seconds: number of seconds to wait 232 """ 233 self.send(Command.SLEEP_CMD.value.format(seconds=seconds)) 234 self.logger.debug("Sleep command sent.")
Tells the HPLC to wait for a specified number of seconds.
Parameters
- seconds: number of seconds to wait
236 def standby(self): 237 """Switches all modules in standby mode. All lamps and pumps are switched off.""" 238 self.send(Command.STANDBY_CMD) 239 self.logger.debug("Standby command sent.")
Switches all modules in standby mode. All lamps and pumps are switched off.
241 def preprun(self): 242 """ Prepares all modules for run. All lamps and pumps are switched on.""" 243 self.send(Command.PREPRUN_CMD) 244 self.logger.debug("PrepRun command sent.")
Prepares all modules for run. All lamps and pumps are switched on.
246 def status(self) -> list[Union[HPLCRunningStatus, HPLCAvailStatus, HPLCErrorStatus]]: 247 """Get device status(es). 248 249 :return: list of ChemStation's current status 250 """ 251 self.send(Command.GET_STATUS_CMD) 252 time.sleep(1) 253 254 try: 255 parsed_response = self.receive().splitlines()[1].split()[1:] 256 except IOError: 257 return [HPLCErrorStatus.NORESPONSE] 258 except IndexError: 259 return [HPLCErrorStatus.MALFORMED] 260 recieved_status = [str_to_status(res) for res in parsed_response] 261 self._most_recent_hplc_status = recieved_status[0] 262 return recieved_status
Get device status(es).
Returns
list of ChemStation's current status
264 def stop_macro(self): 265 """Stops Macro execution. Connection will be lost.""" 266 self.send(Command.STOP_MACRO_CMD)
Stops Macro execution. Connection will be lost.
268 def add_table_row(self, table: Table): 269 """Adds a row to the provided table for currently loaded method or sequence. 270 Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants. 271 You can also provide your own table. 272 273 :param table: the table to add a new row to 274 """ 275 self.sleepy_send(TableOperation.NEW_ROW.value.format(register=table.register, table_name=table.name))
Adds a row to the provided table for currently loaded method or sequence. Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants. You can also provide your own table.
Parameters
- table: the table to add a new row to
277 def delete_table(self, table: Table): 278 """Deletes the table for the current loaded method or sequence. 279 Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants. 280 You can also provide your own table. 281 282 :param table: the table to delete 283 """ 284 self.sleepy_send(TableOperation.DELETE_TABLE.value.format(register=table.register, table_name=table.name))
Deletes the table for the current loaded method or sequence. Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants. You can also provide your own table.
Parameters
- table: the table to delete
286 def new_table(self, table: Table): 287 """Creates the table for the currently loaded method or sequence. 288 Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants. 289 You can also provide your own table. 290 291 :param table: the table to create 292 """ 293 self.send(TableOperation.CREATE_TABLE.value.format(register=table.register, table_name=table.name))
Creates the table for the currently loaded method or sequence. Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants. You can also provide your own table.
Parameters
- table: the table to create
305 def edit_sequence_table(self, sequence_table: SequenceTable): 306 """Updates the currently loaded sequence table with the provided table. This method will delete the existing sequence table and remake it. 307 If you would only like to edit a single row of a sequence table, use `edit_sequence_table_row` instead. 308 309 :param sequence_table: 310 """ 311 self.send("Local Rows") 312 self.sleep(1) 313 self.delete_table(SEQUENCE_TABLE) 314 self.sleep(1) 315 self.new_table(SEQUENCE_TABLE) 316 self.sleep(1) 317 self._get_table_rows(SEQUENCE_TABLE) 318 for _ in sequence_table.rows: 319 self.add_table_row(SEQUENCE_TABLE) 320 self.sleep(1) 321 self.send(Command.SAVE_SEQUENCE_CMD) 322 self.sleep(1) 323 self._get_table_rows(SEQUENCE_TABLE) 324 self.send(Command.SWITCH_SEQUENCE_CMD) 325 for i, row in enumerate(sequence_table.rows): 326 self.edit_sequence_table_row(row=row, row_num=i + 1) 327 self.sleep(1) 328 self.send(Command.SAVE_SEQUENCE_CMD) 329 self.sleep(1)
Updates the currently loaded sequence table with the provided table. This method will delete the existing sequence table and remake it.
If you would only like to edit a single row of a sequence table, use edit_sequence_table_row
instead.
Parameters
- sequence_table:
331 def edit_sequence_table_row(self, row: SequenceEntry, row_num: int): 332 """Edits a row in the sequence table. Assumes the row already exists. 333 334 :param row: sequence row entry with updated information 335 :param row_num: the row to edit, based on 1-based indexing 336 """ 337 338 if row.vial_location: 339 self.sleepy_send(TableOperation.EDIT_ROW_VAL.value.format(register=SEQUENCE_TABLE.register, 340 table_name=SEQUENCE_TABLE.name, 341 row=row_num, 342 col_name=RegisterFlag.VIAL_LOCATION, 343 val=row.vial_location)) 344 if row.method: 345 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 346 table_name=SEQUENCE_TABLE.name, 347 row=row_num, 348 col_name=RegisterFlag.METHOD, 349 val=row.method)) 350 351 if row.num_inj: 352 self.sleepy_send(TableOperation.EDIT_ROW_VAL.value.format(register=SEQUENCE_TABLE.register, 353 table_name=SEQUENCE_TABLE.name, 354 row=row_num, 355 col_name=RegisterFlag.NUM_INJ, 356 val=row.num_inj)) 357 358 if row.inj_vol: 359 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 360 table_name=SEQUENCE_TABLE.name, 361 row=row_num, 362 col_name=RegisterFlag.INJ_VOL, 363 val=row.inj_vol)) 364 365 if row.inj_source: 366 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 367 table_name=SEQUENCE_TABLE.name, 368 row=row_num, 369 col_name=RegisterFlag.INJ_SOR, 370 val=row.inj_source)) 371 372 if row.sample_name: 373 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 374 table_name=SEQUENCE_TABLE.name, 375 row=row_num, 376 col_name=RegisterFlag.NAME, 377 val=row.sample_name)) 378 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 379 table_name=SEQUENCE_TABLE.name, 380 row=row_num, 381 col_name=RegisterFlag.DATA_FILE, 382 val=row.sample_name)) 383 if row.data_file: 384 self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=SEQUENCE_TABLE.register, 385 table_name=SEQUENCE_TABLE.name, 386 row=row_num, 387 col_name=RegisterFlag.DATA_FILE, 388 val=row.sample_name)) 389 390 if row.sample_type: 391 self.sleepy_send(TableOperation.EDIT_ROW_VAL.value.format(register=SEQUENCE_TABLE.register, 392 table_name=SEQUENCE_TABLE.name, 393 row=row_num, 394 col_name=RegisterFlag.SAMPLE_TYPE, 395 val=row.sample_type))
Edits a row in the sequence table. Assumes the row already exists.
Parameters
- row: sequence row entry with updated information
- row_num: the row to edit, based on 1-based indexing
397 def edit_method(self, updated_method: MethodTimetable): 398 """Updated the currently loaded method in ChemStation with provided values. 399 400 :param updated_method: the method with updated values, to be sent to Chemstation to modify the currently loaded method. 401 """ 402 initial_organic_modifier: Param = updated_method.first_row.organic_modifier 403 max_time: Param = updated_method.first_row.maximum_run_time 404 temp: Param = updated_method.first_row.temperature 405 injvol: Param = updated_method.first_row.inj_vol 406 equalib_time: Param = updated_method.first_row.equ_time 407 flow: Param = updated_method.first_row.flow 408 409 # Method settings required for all runs 410 self.send(TableOperation.DELETE_TABLE.value.format(register=METHOD_TIMETABLE.register, 411 table_name=METHOD_TIMETABLE.name, )) 412 self._update_method_param(initial_organic_modifier) 413 self._update_method_param(flow) 414 self._update_method_param(Param(val="Set", 415 chemstation_key=RegisterFlag.STOPTIME_MODE, 416 ptype=PType.STR)) 417 self._update_method_param(max_time) 418 self._update_method_param(Param(val="Off", 419 chemstation_key=RegisterFlag.POSTIME_MODE, 420 ptype=PType.STR)) 421 422 self.send("DownloadRCMethod PMP1") 423 424 self._update_method_timetable(updated_method.subsequent_rows)
Updated the currently loaded method in ChemStation with provided values.
Parameters
- updated_method: the method with updated values, to be sent to Chemstation to modify the currently loaded method.
491 def desired_method_already_loaded(self, method_name: str) -> bool: 492 """Checks if a given method is already loaded into Chemstation. Method name does not need the ".M" extension. 493 494 :param method_name: a Chemstation method 495 :return: True if method is already loaded 496 """ 497 self.send(Command.GET_METHOD_CMD) 498 parsed_response = self.receive().splitlines()[1].split()[1:][0] 499 return method_name in parsed_response
Checks if a given method is already loaded into Chemstation. Method name does not need the ".M" extension.
Parameters
- method_name: a Chemstation method
Returns
True if method is already loaded
501 def switch_method(self, method_name: str): 502 """Allows the user to switch between pre-programmed methods. No need to append '.M' 503 to the end of the method name. For example. for the method named 'General-Poroshell.M', 504 only 'General-Poroshell' is needed. 505 506 :param method_name: any available method in Chemstation method directory 507 :raise IndexError: Response did not have expected format. Try again. 508 :raise AssertionError: The desired method is not selected. Try again. 509 """ 510 self.send( 511 Command.SWITCH_METHOD_CMD.value.format(method_dir=self.method_dir, method_name=method_name) 512 ) 513 514 time.sleep(2) 515 self.send(Command.GET_METHOD_CMD) 516 time.sleep(2) 517 # check that method switched 518 for _ in range(10): 519 try: 520 parsed_response = self.receive().splitlines()[1].split()[1:][0] 521 break 522 except IndexError: 523 self.logger.debug("Malformed response. Trying again.") 524 continue 525 526 assert parsed_response == f"{method_name}.M", "Switching Methods failed."
Allows the user to switch between pre-programmed methods. No need to append '.M' to the end of the method name. For example. for the method named 'General-Poroshell.M', only 'General-Poroshell' is needed.
Parameters
- method_name: any available method in Chemstation method directory :raise IndexError: Response did not have expected format. Try again. :raise AssertionError: The desired method is not selected. Try again.
528 def load_method_details(self, method_name: str) -> MethodTimetable: 529 """Retrieve method details of an existing method. Don't need to append ".M" to the end. This assumes the 530 organic modifier is in Channel B and that Channel A contains the aq layer. Additionally, assumes 531 only two solvents are being used. 532 533 :param method_name: name of method to load details of 534 :raises FileNotFoundError: Method does not exist 535 :return: method details 536 """ 537 method_folder = f"{method_name}.M" 538 method_path = os.path.join(self.method_dir, method_folder, "AgilentPumpDriver1.RapidControl.MethodXML.xml") 539 dad_path = os.path.join(self.method_dir, method_folder, "Agilent1200erDadDriver1.RapidControl.MethodXML.xml") 540 541 if os.path.exists(os.path.join(self.method_dir, f"{method_name}.M")): 542 parser = XmlParser() 543 method = parser.parse(method_path, PumpMethod) 544 dad = parser.parse(dad_path, DadMethod) 545 546 organic_modifier = None 547 aq_modifier = None 548 549 if len(method.solvent_composition.solvent_element) == 2: 550 for solvent in method.solvent_composition.solvent_element: 551 if solvent.channel == "Channel_A": 552 aq_modifier = solvent 553 elif solvent.channel == "Channel_B": 554 organic_modifier = solvent 555 556 return MethodTimetable( 557 first_row=HPLCMethodParams( 558 organic_modifier=Param(val=organic_modifier.percentage, 559 chemstation_key=RegisterFlag.SOLVENT_B_COMPOSITION, 560 ptype=PType.NUM), 561 flow=Param(val=method.flow, 562 chemstation_key=RegisterFlag.FLOW, 563 ptype=PType.NUM), 564 maximum_run_time=Param(val=method.stop_time, 565 chemstation_key=RegisterFlag.MAX_TIME, 566 ptype=PType.NUM), 567 temperature=Param(val=None, 568 chemstation_key=[RegisterFlag.COLUMN_OVEN_TEMP1, 569 RegisterFlag.COLUMN_OVEN_TEMP2], 570 ptype=PType.NUM), 571 inj_vol=Param(val=None, 572 chemstation_key=None, 573 ptype=PType.NUM), 574 equ_time=Param(val=None, 575 chemstation_key=None, 576 ptype=PType.NUM)), 577 subsequent_rows=[ 578 Entry( 579 start_time=tte.time, 580 organic_modifer=tte.percent_b, 581 flow=method.flow 582 ) for tte in method.timetable.timetable_entry 583 ], 584 dad_wavelengthes=dad.signals.signal, 585 organic_modifier=organic_modifier, 586 modifier_a=aq_modifier 587 ) 588 else: 589 raise FileNotFoundError
Retrieve method details of an existing method. Don't need to append ".M" to the end. This assumes the organic modifier is in Channel B and that Channel A contains the aq layer. Additionally, assumes only two solvents are being used.
Parameters
- method_name: name of method to load details of
Raises
- FileNotFoundError: Method does not exist
Returns
method details
607 def switch_sequence(self, seq_name: str): 608 """Switch to the specified sequence. The sequence name does not need the '.S' extension. 609 :param seq_name: The name of the sequence file 610 :param sequence_dir: The directory where the sequence file resides 611 """ 612 self.send(f'_SeqFile$ = "{seq_name}.S"') 613 self.send(f'_SeqPath$ = "{self.sequence_dir}"') 614 self.send(Command.SWITCH_SEQUENCE_CMD) 615 616 time.sleep(2) 617 self.send(Command.GET_SEQUENCE_CMD) 618 time.sleep(2) 619 # check that method switched 620 for _ in range(10): 621 try: 622 parsed_response = self.receive().splitlines()[1].split()[1:][0] 623 break 624 except IndexError: 625 self.logger.debug("Malformed response. Trying again.") 626 continue 627 628 assert parsed_response == f"{seq_name}.S", "Switching sequence failed."
Switch to the specified sequence. The sequence name does not need the '.S' extension.
Parameters
- seq_name: The name of the sequence file
- sequence_dir: The directory where the sequence file resides
630 def run_sequence(self, sequence_table: SequenceTable): 631 """Starts the currently loaded sequence, storing data 632 under the <data_dir>/<sequence table name> folder. 633 The <sequence table name> will be appended with a timestamp in the "%Y %m %d %H %m %s" format. 634 Device must be ready. 635 636 :param sequence_table: 637 """ 638 timestamp = time.strftime(SEQUENCE_TIME_FORMAT) 639 640 self.send(Command.RUN_SEQUENCE_CMD.value) 641 642 folder_name = f"{sequence_table.name} {timestamp}" 643 self.data_files.append(os.path.join(self.data_dir, folder_name)) 644 self.logger.info("Started HPLC sequence: %s.", folder_name) 645 646 subdirs = [x[0] for x in os.walk(self.data_dir)] 647 648 time.sleep(60) 649 650 potential_folders = sorted(list(filter(lambda d: folder_name in d, subdirs))) 651 652 print(potential_folders)
Starts the currently loaded sequence, storing data
under the
Parameters
- sequence_table:
654 def start_method(self): 655 """Starts and executes currently loaded method to run according to Run Time Checklist. Device must be ready. 656 Does not store the folder where data is saved. 657 """ 658 self.send(Command.START_METHOD_CMD)
Starts and executes currently loaded method to run according to Run Time Checklist. Device must be ready. Does not store the folder where data is saved.
660 def run_method(self, experiment_name: str): 661 """This is the preferred method to trigger a run. 662 Starts the currently selected method, storing data 663 under the <data_dir>/<experiment_name>.D folder. 664 The <experiment_name> will be appended with a timestamp in the '%Y-%m-%d-%H-%M' format. 665 Device must be ready. 666 667 :param experiment_name: Name of the experiment 668 """ 669 timestamp = time.strftime(TIME_FORMAT) 670 671 self.send( 672 Command.RUN_METHOD_CMD.value.format( 673 data_dir=self.data_dir, experiment_name=experiment_name, timestamp=timestamp 674 ) 675 ) 676 677 folder_name = f"{experiment_name}_{timestamp}.D" 678 self.data_files.append(os.path.join(self.data_dir, folder_name)) 679 self.logger.info("Started HPLC run: %s.", folder_name)
This is the preferred method to trigger a run.
Starts the currently selected method, storing data
under the
Parameters
- experiment_name: Name of the experiment
681 def stop_method(self): 682 """Stops the run. A dialog window will pop up and manual intervention may be required.""" 683 self.send(Command.STOP_METHOD_CMD)
Stops the run. A dialog window will pop up and manual intervention may be required.
685 def get_spectrum(self): 686 """ Load last chromatogram for any channel in spectra dictionary.""" 687 last_file = self.data_files[-1] if len(self.data_files) > 0 else None 688 689 if last_file is None: 690 raise IndexError 691 692 for channel, spec in self.spectra.items(): 693 spec.load_spectrum(data_path=last_file, channel=channel) 694 self.logger.info("%s chromatogram loaded.", channel)
Load last chromatogram for any channel in spectra dictionary.