pychemstation.control.hplc

Module to provide API for the remote control of the Agilent HPLC systems.

HPLCController sends commands to Chemstation software via a command file. Answers are received via reply file. On the Chemstation side, a custom Macro monitors the command file, executes commands and writes to the reply file. Each command is given a number (cmd_no) to keep track of which commands have been processed.

Authors: Alexander Hammer, Hessam Mehr, Lucy Hao

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

Prepares all modules for run. All lamps and pumps are switched on.

246    def status(self) -> list[Union[HPLCRunningStatus, HPLCAvailStatus, HPLCErrorStatus]]:
247        """Get device status(es).
248
249        :return: list of ChemStation's current status
250        """
251        self.send(Command.GET_STATUS_CMD)
252        time.sleep(1)
253
254        try:
255            parsed_response = self.receive().splitlines()[1].split()[1:]
256        except IOError:
257            return [HPLCErrorStatus.NORESPONSE]
258        except IndexError:
259            return [HPLCErrorStatus.MALFORMED]
260        recieved_status = [str_to_status(res) for res in parsed_response]
261        self._most_recent_hplc_status = recieved_status[0]
262        return recieved_status

Get device status(es).

Returns

list of ChemStation's current status

def stop_macro(self):
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):
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):
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):
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):
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):
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):
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:
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):
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:
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):
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):
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):
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):
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):
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):
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):
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):
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):
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):
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.