pychemstation.control.hplc API documentation

# 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

View Source

      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)

class HPLCController: View Source

     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.

HPLCController( comm_dir: str, data_dir: str, method_dir: str, sequence_dir: str, cmd_file: str = 'cmd', reply_file: str = 'reply') View Source

    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` or `comm_dir` is not a valid directory.

spectra
data_files: list[str]
internal_variables: list[dict[str, str]]
logger
def check_hplc_ready_with_data(self) -> bool: View Source

    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.

def sleepy_send(self, cmd: Union[pychemstation.utils.hplc_param_types.Command, str]): View Source

    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")

def send(self, cmd: Union[pychemstation.utils.hplc_param_types.Command, str]): View Source

    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

def receive(self) -> str: View Source

    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

def reset_cmd_counter(self): View Source

    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.

def sleep(self, seconds: int): View Source

    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

def standby(self): View Source

    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.

def preprun(self): View Source

    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.

def status( self) -> list[typing.Union[pychemstation.utils.hplc_param_types.HPLCRunningStatus, pychemstation.utils.hplc_param_types.HPLCAvailStatus, pychemstation.utils.hplc_param_types.HPLCErrorStatus]]: View Source

    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

def stop_macro(self): View Source

    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.

def add_table_row(self, table: pychemstation.utils.hplc_param_types.Table): View Source

    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

def delete_table(self, table: pychemstation.utils.hplc_param_types.Table): View Source

    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

def new_table(self, table: pychemstation.utils.hplc_param_types.Table): View Source

    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

def edit_sequence_table( self, sequence_table: pychemstation.utils.hplc_param_types.SequenceTable): View Source

    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:

def edit_sequence_table_row( self, row: pychemstation.utils.hplc_param_types.SequenceEntry, row_num: int): View Source

    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

def edit_method( self, updated_method: pychemstation.utils.hplc_param_types.MethodTimetable): View Source

    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.

def desired_method_already_loaded(self, method_name: str) -> bool: View Source

    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

def switch_method(self, method_name: str): View Source

    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.

def load_method_details( self, method_name: str) -> pychemstation.utils.hplc_param_types.MethodTimetable: View Source

    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

def lamp_on(self): View Source

    591    def lamp_on(self):
    592        """Turns the UV lamp on."""
    593        self.send(Command.LAMP_ON_CMD)

Turns the UV lamp on.

def lamp_off(self): View Source

    595    def lamp_off(self):
    596        """Turns the UV lamp off."""
    597        self.send(Command.LAMP_OFF_CMD)

Turns the UV lamp off.

def pump_on(self): View Source

    599    def pump_on(self):
    600        """Turns on the pump on."""
    601        self.send(Command.PUMP_ON_CMD)

Turns on the pump on.

def pump_off(self): View Source

    603    def pump_off(self):
    604        """Turns the pump off."""
    605        self.send(Command.PUMP_OFF_CMD)

Turns the pump off.

def switch_sequence(self, seq_name: str): View Source

    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

def run_sequence( self, sequence_table: pychemstation.utils.hplc_param_types.SequenceTable): View Source

    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 / folder. The will be appended with a timestamp in the "%Y %m %d %H %m %s" format. Device must be ready.

###### Parameters

- sequence_table:

def start_method(self): View Source

    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.

def run_method(self, experiment_name: str): View Source

    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 /.D folder. The will be appended with a timestamp in the '%Y-%m-%d-%H-%M' format. Device must be ready.

###### Parameters

- experiment_name: Name of the experiment

def stop_method(self): View Source

    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.

def get_spectrum(self): View Source

    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.