pychemstation 0.5.4__py3-none-any.whl → 0.5.6.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pychemstation/control/__init__.py +0 -1
- pychemstation/control/controllers/__init__.py +4 -0
- pychemstation/control/controllers/comm.py +206 -0
- pychemstation/control/controllers/method.py +284 -0
- pychemstation/control/controllers/sequence.py +213 -0
- pychemstation/control/controllers/table_controller.py +208 -0
- pychemstation/control/hplc.py +6 -7
- pychemstation/control/table/table_controller.py +19 -16
- pychemstation/utils/chromatogram.py +8 -27
- {pychemstation-0.5.4.dist-info → pychemstation-0.5.6.dev1.dist-info}/METADATA +10 -8
- {pychemstation-0.5.4.dist-info → pychemstation-0.5.6.dev1.dist-info}/RECORD +15 -10
- tests/test_comb.py +2 -2
- {pychemstation-0.5.4.dist-info → pychemstation-0.5.6.dev1.dist-info}/LICENSE +0 -0
- {pychemstation-0.5.4.dist-info → pychemstation-0.5.6.dev1.dist-info}/WHEEL +0 -0
- {pychemstation-0.5.4.dist-info → pychemstation-0.5.6.dev1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,206 @@
|
|
1
|
+
"""
|
2
|
+
Module to provide API for the communication with Agilent HPLC systems.
|
3
|
+
|
4
|
+
HPLCController sends commands to Chemstation software via a command file.
|
5
|
+
Answers are received via reply file. On the Chemstation side, a custom
|
6
|
+
Macro monitors the command file, executes commands and writes to the reply file.
|
7
|
+
Each command is given a number (cmd_no) to keep track of which commands have
|
8
|
+
been processed.
|
9
|
+
|
10
|
+
Authors: Alexander Hammer, Hessam Mehr, Lucy Hao
|
11
|
+
"""
|
12
|
+
|
13
|
+
import os
|
14
|
+
import time
|
15
|
+
|
16
|
+
from result import Result, Ok, Err
|
17
|
+
|
18
|
+
from ...utils.macro import *
|
19
|
+
|
20
|
+
|
21
|
+
class CommunicationController:
|
22
|
+
"""
|
23
|
+
Class that communicates with Agilent using Macros
|
24
|
+
"""
|
25
|
+
|
26
|
+
# maximum command number
|
27
|
+
MAX_CMD_NO = 255
|
28
|
+
|
29
|
+
def __init__(
|
30
|
+
self,
|
31
|
+
comm_dir: str,
|
32
|
+
cmd_file: str = "cmd",
|
33
|
+
reply_file: str = "reply",
|
34
|
+
):
|
35
|
+
"""
|
36
|
+
:param comm_dir:
|
37
|
+
:param cmd_file: Name of command file
|
38
|
+
:param reply_file: Name of reply file
|
39
|
+
"""
|
40
|
+
if os.path.isdir(comm_dir):
|
41
|
+
self.cmd_file = os.path.join(comm_dir, cmd_file)
|
42
|
+
self.reply_file = os.path.join(comm_dir, reply_file)
|
43
|
+
self.cmd_no = 0
|
44
|
+
else:
|
45
|
+
raise FileNotFoundError(f"comm_dir: {comm_dir} not found.")
|
46
|
+
self._most_recent_hplc_status = None
|
47
|
+
|
48
|
+
# Create files for Chemstation to communicate with Python
|
49
|
+
open(self.cmd_file, "a").close()
|
50
|
+
open(self.reply_file, "a").close()
|
51
|
+
|
52
|
+
self.reset_cmd_counter()
|
53
|
+
|
54
|
+
def get_num_val(self, cmd: str) -> Union[int, float, Err]:
|
55
|
+
self.send(Command.GET_NUM_VAL_CMD.value.format(cmd=cmd))
|
56
|
+
res = self.receive()
|
57
|
+
if res.is_ok():
|
58
|
+
return res.ok_value.num_response
|
59
|
+
else:
|
60
|
+
raise RuntimeError("Failed to get number.")
|
61
|
+
|
62
|
+
def get_text_val(self, cmd: str) -> str:
|
63
|
+
self.send(Command.GET_TEXT_VAL_CMD.value.format(cmd=cmd))
|
64
|
+
res = self.receive()
|
65
|
+
if res.is_ok():
|
66
|
+
return res.ok_value.string_response
|
67
|
+
else:
|
68
|
+
raise RuntimeError("Failed to get string")
|
69
|
+
|
70
|
+
def get_status(self) -> Union[HPLCRunningStatus, HPLCAvailStatus, HPLCErrorStatus]:
|
71
|
+
"""Get device status(es).
|
72
|
+
|
73
|
+
:return: list of ChemStation's current status
|
74
|
+
"""
|
75
|
+
self.send(Command.GET_STATUS_CMD)
|
76
|
+
time.sleep(1)
|
77
|
+
|
78
|
+
try:
|
79
|
+
parsed_response = self.receive().value.string_response
|
80
|
+
self._most_recent_hplc_status = str_to_status(parsed_response)
|
81
|
+
return self._most_recent_hplc_status
|
82
|
+
except IOError:
|
83
|
+
return HPLCErrorStatus.NORESPONSE
|
84
|
+
except IndexError:
|
85
|
+
return HPLCErrorStatus.MALFORMED
|
86
|
+
|
87
|
+
def set_status(self):
|
88
|
+
"""Updates current status of HPLC machine"""
|
89
|
+
self._most_recent_hplc_status = self.get_status()
|
90
|
+
|
91
|
+
def check_if_running(self) -> bool:
|
92
|
+
"""Checks if HPLC machine is in an available state, meaning a state that data is not being written.
|
93
|
+
|
94
|
+
:return: whether the HPLC machine is in a safe state to retrieve data back."""
|
95
|
+
self.set_status()
|
96
|
+
hplc_avail = isinstance(self._most_recent_hplc_status, HPLCAvailStatus)
|
97
|
+
time.sleep(30)
|
98
|
+
self.set_status()
|
99
|
+
hplc_actually_avail = isinstance(self._most_recent_hplc_status, HPLCAvailStatus)
|
100
|
+
return hplc_avail and hplc_actually_avail
|
101
|
+
|
102
|
+
def _send(self, cmd: str, cmd_no: int, num_attempts=5) -> None:
|
103
|
+
"""Low-level execution primitive. Sends a command string to HPLC.
|
104
|
+
|
105
|
+
:param cmd: string to be sent to HPLC
|
106
|
+
:param cmd_no: Command number
|
107
|
+
:param num_attempts: Number of attempts to send the command before raising exception.
|
108
|
+
:raises IOError: Could not write to command file.
|
109
|
+
"""
|
110
|
+
err = None
|
111
|
+
for _ in range(num_attempts):
|
112
|
+
time.sleep(1)
|
113
|
+
try:
|
114
|
+
with open(self.cmd_file, "w", encoding="utf8") as cmd_file:
|
115
|
+
cmd_file.write(f"{cmd_no} {cmd}")
|
116
|
+
except IOError as e:
|
117
|
+
err = e
|
118
|
+
continue
|
119
|
+
else:
|
120
|
+
return
|
121
|
+
else:
|
122
|
+
raise IOError(f"Failed to send command #{cmd_no}: {cmd}.") from err
|
123
|
+
|
124
|
+
def _receive(self, cmd_no: int, num_attempts=100) -> Result[str, str]:
|
125
|
+
"""Low-level execution primitive. Recives a response from HPLC.
|
126
|
+
|
127
|
+
:param cmd_no: Command number
|
128
|
+
:param num_attempts: Number of retries to open reply file
|
129
|
+
:raises IOError: Could not read reply file.
|
130
|
+
:return: Potential ChemStation response
|
131
|
+
"""
|
132
|
+
err = None
|
133
|
+
for _ in range(num_attempts):
|
134
|
+
time.sleep(1)
|
135
|
+
|
136
|
+
try:
|
137
|
+
with open(self.reply_file, "r", encoding="utf_16") as reply_file:
|
138
|
+
response = reply_file.read()
|
139
|
+
except OSError as e:
|
140
|
+
err = e
|
141
|
+
continue
|
142
|
+
|
143
|
+
try:
|
144
|
+
first_line = response.splitlines()[0]
|
145
|
+
response_no = int(first_line.split()[0])
|
146
|
+
except IndexError as e:
|
147
|
+
err = e
|
148
|
+
continue
|
149
|
+
|
150
|
+
# check that response corresponds to sent command
|
151
|
+
if response_no == cmd_no:
|
152
|
+
return Ok(response)
|
153
|
+
else:
|
154
|
+
continue
|
155
|
+
else:
|
156
|
+
return Err(f"Failed to receive reply to command #{cmd_no} due to {err}.")
|
157
|
+
|
158
|
+
def sleepy_send(self, cmd: Union[Command, str]):
|
159
|
+
self.send("Sleep 0.1")
|
160
|
+
self.send(cmd)
|
161
|
+
self.send("Sleep 0.1")
|
162
|
+
|
163
|
+
def send(self, cmd: Union[Command, str]):
|
164
|
+
"""Sends a command to Chemstation.
|
165
|
+
|
166
|
+
:param cmd: Command to be sent to HPLC
|
167
|
+
"""
|
168
|
+
if self.cmd_no == self.MAX_CMD_NO:
|
169
|
+
self.reset_cmd_counter()
|
170
|
+
|
171
|
+
cmd_to_send: str = cmd.value if isinstance(cmd, Command) else cmd
|
172
|
+
self.cmd_no += 1
|
173
|
+
self._send(cmd_to_send, self.cmd_no)
|
174
|
+
f = open("out.txt", "a")
|
175
|
+
f.write(cmd_to_send + "\n")
|
176
|
+
f.close()
|
177
|
+
|
178
|
+
def receive(self) -> Result[Response, str]:
|
179
|
+
"""Returns messages received in reply file.
|
180
|
+
|
181
|
+
:return: ChemStation response
|
182
|
+
"""
|
183
|
+
num_response_prefix = "Numerical Responses:"
|
184
|
+
str_response_prefix = "String Responses:"
|
185
|
+
possible_response = self._receive(self.cmd_no)
|
186
|
+
if Ok(possible_response):
|
187
|
+
lines = possible_response.value.splitlines()
|
188
|
+
for line in lines:
|
189
|
+
if str_response_prefix in line and num_response_prefix in line:
|
190
|
+
string_responses_dirty, _, numerical_responses = line.partition(num_response_prefix)
|
191
|
+
_, _, string_responses = string_responses_dirty.partition(str_response_prefix)
|
192
|
+
return Ok(Response(string_response=string_responses.strip(),
|
193
|
+
num_response=float(numerical_responses.strip())))
|
194
|
+
return Err(f"Could not retrieve HPLC response")
|
195
|
+
else:
|
196
|
+
return Err(f"Could not establish response to HPLC: {possible_response}")
|
197
|
+
|
198
|
+
def reset_cmd_counter(self):
|
199
|
+
"""Resets the command counter."""
|
200
|
+
self._send(Command.RESET_COUNTER_CMD.value, cmd_no=self.MAX_CMD_NO + 1)
|
201
|
+
self._receive(cmd_no=self.MAX_CMD_NO + 1)
|
202
|
+
self.cmd_no = 0
|
203
|
+
|
204
|
+
def stop_macro(self):
|
205
|
+
"""Stops Macro execution. Connection will be lost."""
|
206
|
+
self.send(Command.STOP_MACRO_CMD)
|
@@ -0,0 +1,284 @@
|
|
1
|
+
import os
|
2
|
+
import time
|
3
|
+
from typing import Optional
|
4
|
+
|
5
|
+
from xsdata.formats.dataclass.parsers import XmlParser
|
6
|
+
|
7
|
+
from ...control.controllers.table_controller import TableController
|
8
|
+
from ...control.controllers.comm import CommunicationController
|
9
|
+
from ...generated import PumpMethod, DadMethod, SolventElement
|
10
|
+
from ...utils.chromatogram import TIME_FORMAT, AgilentChannelChromatogramData
|
11
|
+
from ...utils.macro import Command
|
12
|
+
from ...utils.method_types import PType, TimeTableEntry, Param, MethodTimetable, HPLCMethodParams
|
13
|
+
from ...utils.table_types import RegisterFlag, TableOperation, Table
|
14
|
+
|
15
|
+
|
16
|
+
class MethodController(TableController):
|
17
|
+
"""
|
18
|
+
Class containing method related logic
|
19
|
+
"""
|
20
|
+
|
21
|
+
def __init__(self, controller: CommunicationController, src: str, data_dir: str, table: Table):
|
22
|
+
super().__init__(controller, src, data_dir, table)
|
23
|
+
|
24
|
+
def get_method_params(self) -> HPLCMethodParams:
|
25
|
+
return HPLCMethodParams(organic_modifier=self.controller.get_num_val(
|
26
|
+
cmd=TableOperation.GET_OBJ_HDR_VAL.value.format(
|
27
|
+
register=self.table.register,
|
28
|
+
register_flag=RegisterFlag.SOLVENT_B_COMPOSITION
|
29
|
+
)
|
30
|
+
),
|
31
|
+
flow=self.controller.get_num_val(
|
32
|
+
cmd=TableOperation.GET_OBJ_HDR_VAL.value.format(
|
33
|
+
register=self.table.register,
|
34
|
+
register_flag=RegisterFlag.FLOW
|
35
|
+
)
|
36
|
+
),
|
37
|
+
maximum_run_time=self.controller.get_num_val(
|
38
|
+
cmd=TableOperation.GET_OBJ_HDR_VAL.value.format(
|
39
|
+
register=self.table.register,
|
40
|
+
register_flag=RegisterFlag.MAX_TIME
|
41
|
+
)
|
42
|
+
),
|
43
|
+
)
|
44
|
+
|
45
|
+
def get_row(self, row: int) -> TimeTableEntry:
|
46
|
+
return TimeTableEntry(start_time=self.get_num(row, RegisterFlag.TIME),
|
47
|
+
organic_modifer=self.get_num(row, RegisterFlag.TIMETABLE_SOLVENT_B_COMPOSITION),
|
48
|
+
flow=None)
|
49
|
+
|
50
|
+
def load(self) -> MethodTimetable:
|
51
|
+
rows = self.get_num_rows()
|
52
|
+
if rows.is_ok():
|
53
|
+
timetable_rows = [self.get_row(r + 1) for r in range(int(rows.ok_value.num_response))]
|
54
|
+
self.table_state = MethodTimetable(
|
55
|
+
first_row=self.get_method_params(),
|
56
|
+
subsequent_rows=timetable_rows)
|
57
|
+
return self.table_state
|
58
|
+
else:
|
59
|
+
raise RuntimeError(rows.err_value)
|
60
|
+
|
61
|
+
def current_method(self, method_name: str):
|
62
|
+
"""
|
63
|
+
Checks if a given method is already loaded into Chemstation. Method name does not need the ".M" extension.
|
64
|
+
|
65
|
+
:param method_name: a Chemstation method
|
66
|
+
:return: True if method is already loaded
|
67
|
+
"""
|
68
|
+
self.send(Command.GET_METHOD_CMD)
|
69
|
+
parsed_response = self.receive()
|
70
|
+
return method_name in parsed_response
|
71
|
+
|
72
|
+
def switch(self, method_name: str):
|
73
|
+
"""
|
74
|
+
Allows the user to switch between pre-programmed methods. No need to append '.M'
|
75
|
+
to the end of the method name. For example. for the method named 'General-Poroshell.M',
|
76
|
+
only 'General-Poroshell' is needed.
|
77
|
+
|
78
|
+
:param method_name: any available method in Chemstation method directory
|
79
|
+
:raise IndexError: Response did not have expected format. Try again.
|
80
|
+
:raise AssertionError: The desired method is not selected. Try again.
|
81
|
+
"""
|
82
|
+
self.send(Command.SWITCH_METHOD_CMD.value.format(method_dir=self.src,
|
83
|
+
method_name=method_name))
|
84
|
+
|
85
|
+
time.sleep(2)
|
86
|
+
self.send(Command.GET_METHOD_CMD)
|
87
|
+
time.sleep(2)
|
88
|
+
res = self.receive()
|
89
|
+
if res.is_ok():
|
90
|
+
parsed_response = res.ok_value.string_response
|
91
|
+
assert parsed_response == f"{method_name}.M", "Switching Methods failed."
|
92
|
+
self.table_state = None
|
93
|
+
|
94
|
+
def load_from_disk(self, method_name: str) -> MethodTimetable:
|
95
|
+
"""
|
96
|
+
Retrieve method details of an existing method. Don't need to append ".M" to the end. This assumes the
|
97
|
+
organic modifier is in Channel B and that Channel A contains the aq layer. Additionally, assumes
|
98
|
+
only two solvents are being used.
|
99
|
+
|
100
|
+
:param method_name: name of method to load details of
|
101
|
+
:raises FileNotFoundError: Method does not exist
|
102
|
+
:return: method details
|
103
|
+
"""
|
104
|
+
method_folder = f"{method_name}.M"
|
105
|
+
method_path = os.path.join(self.src, method_folder, "AgilentPumpDriver1.RapidControl.MethodXML.xml")
|
106
|
+
dad_path = os.path.join(self.src, method_folder, "Agilent1200erDadDriver1.RapidControl.MethodXML.xml")
|
107
|
+
|
108
|
+
if os.path.exists(os.path.join(self.src, f"{method_name}.M")):
|
109
|
+
parser = XmlParser()
|
110
|
+
method = parser.parse(method_path, PumpMethod)
|
111
|
+
dad = parser.parse(dad_path, DadMethod)
|
112
|
+
|
113
|
+
organic_modifier: Optional[SolventElement] = None
|
114
|
+
aq_modifier: Optional[SolventElement] = None
|
115
|
+
|
116
|
+
if len(method.solvent_composition.solvent_element) == 2:
|
117
|
+
for solvent in method.solvent_composition.solvent_element:
|
118
|
+
if solvent.channel == "Channel_A":
|
119
|
+
aq_modifier = solvent
|
120
|
+
elif solvent.channel == "Channel_B":
|
121
|
+
organic_modifier = solvent
|
122
|
+
|
123
|
+
self.table_state = MethodTimetable(
|
124
|
+
first_row=HPLCMethodParams(
|
125
|
+
organic_modifier=organic_modifier.percentage,
|
126
|
+
flow=method.flow,
|
127
|
+
maximum_run_time=method.stop_time,
|
128
|
+
temperature=-1),
|
129
|
+
subsequent_rows=[
|
130
|
+
TimeTableEntry(
|
131
|
+
start_time=tte.time,
|
132
|
+
organic_modifer=tte.percent_b,
|
133
|
+
flow=method.flow
|
134
|
+
) for tte in method.timetable.timetable_entry
|
135
|
+
],
|
136
|
+
dad_wavelengthes=dad.signals.signal,
|
137
|
+
organic_modifier=organic_modifier,
|
138
|
+
modifier_a=aq_modifier
|
139
|
+
)
|
140
|
+
return self.table_state
|
141
|
+
else:
|
142
|
+
raise FileNotFoundError
|
143
|
+
|
144
|
+
def edit(self, updated_method: MethodTimetable, save: bool):
|
145
|
+
"""Updated the currently loaded method in ChemStation with provided values.
|
146
|
+
|
147
|
+
:param updated_method: the method with updated values, to be sent to Chemstation to modify the currently loaded method.
|
148
|
+
"""
|
149
|
+
initial_organic_modifier: Param = Param(val=updated_method.first_row.organic_modifier,
|
150
|
+
chemstation_key=RegisterFlag.SOLVENT_B_COMPOSITION,
|
151
|
+
ptype=PType.NUM)
|
152
|
+
max_time: Param = Param(val=updated_method.first_row.maximum_run_time,
|
153
|
+
chemstation_key=RegisterFlag.MAX_TIME,
|
154
|
+
ptype=PType.NUM)
|
155
|
+
flow: Param = Param(val=updated_method.first_row.flow,
|
156
|
+
chemstation_key=RegisterFlag.FLOW,
|
157
|
+
ptype=PType.NUM)
|
158
|
+
|
159
|
+
# Method settings required for all runs
|
160
|
+
self.delete_table()
|
161
|
+
self._update_param(initial_organic_modifier)
|
162
|
+
self._update_param(flow)
|
163
|
+
self._update_param(Param(val="Set",
|
164
|
+
chemstation_key=RegisterFlag.STOPTIME_MODE,
|
165
|
+
ptype=PType.STR))
|
166
|
+
self._update_param(max_time)
|
167
|
+
self._update_param(Param(val="Off",
|
168
|
+
chemstation_key=RegisterFlag.POSTIME_MODE,
|
169
|
+
ptype=PType.STR))
|
170
|
+
|
171
|
+
self.send("DownloadRCMethod PMP1")
|
172
|
+
|
173
|
+
self._update_method_timetable(updated_method.subsequent_rows)
|
174
|
+
|
175
|
+
if save:
|
176
|
+
self.send(Command.SAVE_METHOD_CMD.value.format(
|
177
|
+
commit_msg=f"saved method at {str(time.time())}"
|
178
|
+
))
|
179
|
+
|
180
|
+
def _update_method_timetable(self, timetable_rows: list[TimeTableEntry]):
|
181
|
+
self.sleepy_send('Local Rows')
|
182
|
+
self.get_num_rows()
|
183
|
+
|
184
|
+
self.sleepy_send('DelTab RCPMP1Method[1], "Timetable"')
|
185
|
+
res = self.get_num_rows()
|
186
|
+
while not res.is_err():
|
187
|
+
self.sleepy_send('DelTab RCPMP1Method[1], "Timetable"')
|
188
|
+
res = self.get_num_rows()
|
189
|
+
|
190
|
+
self.sleepy_send('NewTab RCPMP1Method[1], "Timetable"')
|
191
|
+
self.get_num_rows()
|
192
|
+
|
193
|
+
for i, row in enumerate(timetable_rows):
|
194
|
+
if i == 0:
|
195
|
+
self.send('Sleep 1')
|
196
|
+
self.sleepy_send('InsTabRow RCPMP1Method[1], "Timetable"')
|
197
|
+
self.send('Sleep 1')
|
198
|
+
|
199
|
+
self.sleepy_send('NewColText RCPMP1Method[1], "Timetable", "Function", "SolventComposition"')
|
200
|
+
self.sleepy_send(f'NewColVal RCPMP1Method[1], "Timetable", "Time", {row.start_time}')
|
201
|
+
self.sleepy_send(
|
202
|
+
f'NewColVal RCPMP1Method[1], "Timetable", "SolventCompositionPumpChannel2_Percentage", {row.organic_modifer}')
|
203
|
+
|
204
|
+
self.send('Sleep 1')
|
205
|
+
self.sleepy_send("DownloadRCMethod PMP1")
|
206
|
+
self.send('Sleep 1')
|
207
|
+
else:
|
208
|
+
self.sleepy_send('InsTabRow RCPMP1Method[1], "Timetable"')
|
209
|
+
self.get_num_rows()
|
210
|
+
|
211
|
+
self.sleepy_send(
|
212
|
+
f'SetTabText RCPMP1Method[1], "Timetable", Rows, "Function", "SolventComposition"')
|
213
|
+
self.sleepy_send(
|
214
|
+
f'SetTabVal RCPMP1Method[1], "Timetable", Rows, "Time", {row.start_time}')
|
215
|
+
self.sleepy_send(
|
216
|
+
f'SetTabVal RCPMP1Method[1], "Timetable", Rows, "SolventCompositionPumpChannel2_Percentage", {row.organic_modifer}')
|
217
|
+
|
218
|
+
self.send("Sleep 1")
|
219
|
+
self.sleepy_send("DownloadRCMethod PMP1")
|
220
|
+
self.send("Sleep 1")
|
221
|
+
self.get_num_rows()
|
222
|
+
|
223
|
+
def _update_param(self, method_param: Param):
|
224
|
+
"""Change a method parameter, changes what is visibly seen in Chemstation GUI.
|
225
|
+
(changes the first row in the timetable)
|
226
|
+
|
227
|
+
:param method_param: a parameter to update for currently loaded method.
|
228
|
+
"""
|
229
|
+
register = self.table.register
|
230
|
+
setting_command = TableOperation.UPDATE_OBJ_HDR_VAL if method_param.ptype == PType.NUM else TableOperation.UPDATE_OBJ_HDR_TEXT
|
231
|
+
if isinstance(method_param.chemstation_key, list):
|
232
|
+
for register_flag in method_param.chemstation_key:
|
233
|
+
self.send(setting_command.value.format(register=register,
|
234
|
+
register_flag=register_flag,
|
235
|
+
val=method_param.val))
|
236
|
+
else:
|
237
|
+
self.send(setting_command.value.format(register=register,
|
238
|
+
register_flag=method_param.chemstation_key,
|
239
|
+
val=method_param.val))
|
240
|
+
time.sleep(2)
|
241
|
+
|
242
|
+
def stop(self):
|
243
|
+
"""
|
244
|
+
Stops the method run. A dialog window will pop up and manual intervention may be required.\
|
245
|
+
"""
|
246
|
+
self.send(Command.STOP_METHOD_CMD)
|
247
|
+
|
248
|
+
def run(self, experiment_name: str, stall_while_running: bool = True):
|
249
|
+
"""
|
250
|
+
This is the preferred method to trigger a run.
|
251
|
+
Starts the currently selected method, storing data
|
252
|
+
under the <data_dir>/<experiment_name>.D folder.
|
253
|
+
The <experiment_name> will be appended with a timestamp in the '%Y-%m-%d-%H-%M' format.
|
254
|
+
Device must be ready.
|
255
|
+
|
256
|
+
:param experiment_name: Name of the experiment
|
257
|
+
"""
|
258
|
+
timestamp = time.strftime(TIME_FORMAT)
|
259
|
+
self.send(Command.RUN_METHOD_CMD.value.format(data_dir=self.data_dir,
|
260
|
+
experiment_name=experiment_name,
|
261
|
+
timestamp=timestamp))
|
262
|
+
|
263
|
+
if self.check_hplc_is_running():
|
264
|
+
folder_name = f"{experiment_name}_{timestamp}.D"
|
265
|
+
self.data_files.append(os.path.join(self.data_dir, folder_name))
|
266
|
+
|
267
|
+
if stall_while_running:
|
268
|
+
run_completed = self.check_hplc_done_running(method=self.table_state)
|
269
|
+
if run_completed.is_ok():
|
270
|
+
self.data_files[-1] = run_completed.value
|
271
|
+
else:
|
272
|
+
raise RuntimeError("Run error has occurred.")
|
273
|
+
else:
|
274
|
+
self.data_files[-1].dir = self.fuzzy_match_most_recent_folder(folder_name).ok_value
|
275
|
+
|
276
|
+
def retrieve_recent_data_files(self) -> str:
|
277
|
+
return self.data_files[-1]
|
278
|
+
|
279
|
+
def get_data(self, custom_path: Optional[str] = None) -> AgilentChannelChromatogramData:
|
280
|
+
if not custom_path:
|
281
|
+
self.get_spectrum(self.data_files[-1])
|
282
|
+
else:
|
283
|
+
self.get_spectrum(custom_path)
|
284
|
+
return AgilentChannelChromatogramData(**self.spectra)
|
@@ -0,0 +1,213 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
|
3
|
+
|
4
|
+
import os
|
5
|
+
import time
|
6
|
+
|
7
|
+
from ...control.controllers.table_controller import TableController
|
8
|
+
from ...control.controllers.comm import CommunicationController
|
9
|
+
from ...utils.chromatogram import SEQUENCE_TIME_FORMAT, AgilentChannelChromatogramData
|
10
|
+
from ...utils.macro import Command
|
11
|
+
from ...utils.sequence_types import SequenceTable, SequenceEntry, SequenceDataFiles, InjectionSource, SampleType
|
12
|
+
from ...utils.table_types import TableOperation, RegisterFlag, Table
|
13
|
+
|
14
|
+
|
15
|
+
class SequenceController(TableController):
|
16
|
+
"""
|
17
|
+
Class containing sequence related logic
|
18
|
+
"""
|
19
|
+
|
20
|
+
def __init__(self, controller: CommunicationController, src: str, data_dir: str, table: Table, method_dir: str):
|
21
|
+
self.method_dir = method_dir
|
22
|
+
super().__init__(controller, src, data_dir, table)
|
23
|
+
|
24
|
+
def load(self) -> SequenceTable:
|
25
|
+
rows = self.get_num_rows()
|
26
|
+
self.send(Command.GET_SEQUENCE_CMD)
|
27
|
+
seq_name = self.receive()
|
28
|
+
|
29
|
+
if rows.is_ok() and seq_name.is_ok():
|
30
|
+
self.table_state = SequenceTable(
|
31
|
+
name=seq_name.ok_value.string_response.partition(".S")[0],
|
32
|
+
rows=[self.get_row(r + 1) for r in range(int(rows.ok_value.num_response))])
|
33
|
+
return self.table_state
|
34
|
+
raise RuntimeError(rows.err_value)
|
35
|
+
|
36
|
+
def get_row(self, row: int) -> SequenceEntry:
|
37
|
+
sample_name = self.get_text(row, RegisterFlag.NAME)
|
38
|
+
vial_location = int(self.get_num(row, RegisterFlag.VIAL_LOCATION))
|
39
|
+
method = self.get_text(row, RegisterFlag.METHOD)
|
40
|
+
num_inj = int(self.get_num(row, RegisterFlag.NUM_INJ))
|
41
|
+
inj_vol = int(self.get_text(row, RegisterFlag.INJ_VOL))
|
42
|
+
inj_source = InjectionSource(self.get_text(row, RegisterFlag.INJ_SOR))
|
43
|
+
sample_type = SampleType(self.get_num(row, RegisterFlag.SAMPLE_TYPE))
|
44
|
+
return SequenceEntry(sample_name=sample_name,
|
45
|
+
vial_location=vial_location,
|
46
|
+
method=None if len(method) == 0 else method,
|
47
|
+
num_inj=num_inj,
|
48
|
+
inj_vol=inj_vol,
|
49
|
+
inj_source=inj_source,
|
50
|
+
sample_type=sample_type, )
|
51
|
+
|
52
|
+
def switch(self, seq_name: str):
|
53
|
+
"""
|
54
|
+
Switch to the specified sequence. The sequence name does not need the '.S' extension.
|
55
|
+
|
56
|
+
:param seq_name: The name of the sequence file
|
57
|
+
"""
|
58
|
+
self.send(f'_SeqFile$ = "{seq_name}.S"')
|
59
|
+
self.send(f'_SeqPath$ = "{self.src}"')
|
60
|
+
self.send(Command.SWITCH_SEQUENCE_CMD)
|
61
|
+
time.sleep(2)
|
62
|
+
self.send(Command.GET_SEQUENCE_CMD)
|
63
|
+
time.sleep(2)
|
64
|
+
parsed_response = self.receive().value.string_response
|
65
|
+
|
66
|
+
assert parsed_response == f"{seq_name}.S", "Switching sequence failed."
|
67
|
+
self.table_state = None
|
68
|
+
|
69
|
+
def edit(self, sequence_table: SequenceTable):
|
70
|
+
"""
|
71
|
+
Updates the currently loaded sequence table with the provided table. This method will delete the existing sequence table and remake it.
|
72
|
+
If you would only like to edit a single row of a sequence table, use `edit_sequence_table_row` instead.
|
73
|
+
|
74
|
+
:param sequence_table:
|
75
|
+
"""
|
76
|
+
|
77
|
+
rows = self.get_num_rows()
|
78
|
+
if rows.is_ok():
|
79
|
+
existing_row_num = rows.value.num_response
|
80
|
+
wanted_row_num = len(sequence_table.rows)
|
81
|
+
while existing_row_num != wanted_row_num:
|
82
|
+
if wanted_row_num > existing_row_num:
|
83
|
+
self.add_row()
|
84
|
+
elif wanted_row_num < existing_row_num:
|
85
|
+
self.delete_row(int(existing_row_num))
|
86
|
+
self.send(Command.SAVE_SEQUENCE_CMD)
|
87
|
+
existing_row_num = self.get_num_rows().ok_value.num_response
|
88
|
+
self.send(Command.SWITCH_SEQUENCE_CMD)
|
89
|
+
|
90
|
+
for i, row in enumerate(sequence_table.rows):
|
91
|
+
self.edit_row(row=row, row_num=i + 1)
|
92
|
+
self.sleep(1)
|
93
|
+
self.send(Command.SAVE_SEQUENCE_CMD)
|
94
|
+
self.send(Command.SWITCH_SEQUENCE_CMD)
|
95
|
+
|
96
|
+
def edit_row(self, row: SequenceEntry, row_num: int):
|
97
|
+
"""
|
98
|
+
Edits a row in the sequence table. If a row does NOT exist, a new one will be created.
|
99
|
+
|
100
|
+
:param row: sequence row entry with updated information
|
101
|
+
:param row_num: the row to edit, based on 1-based indexing
|
102
|
+
"""
|
103
|
+
num_rows = self.get_num_rows()
|
104
|
+
if num_rows.is_ok():
|
105
|
+
while num_rows.ok_value.num_response < row_num:
|
106
|
+
self.add_row()
|
107
|
+
self.send(Command.SAVE_SEQUENCE_CMD)
|
108
|
+
num_rows = self.get_num_rows()
|
109
|
+
|
110
|
+
table_register = self.table.register
|
111
|
+
table_name = self.table.name
|
112
|
+
|
113
|
+
if row.vial_location:
|
114
|
+
loc = row.vial_location
|
115
|
+
if isinstance(row.vial_location, InjectionSource):
|
116
|
+
loc = row.vial_location.value
|
117
|
+
self.sleepy_send(TableOperation.EDIT_ROW_VAL.value.format(register=table_register,
|
118
|
+
table_name=table_name,
|
119
|
+
row=row_num,
|
120
|
+
col_name=RegisterFlag.VIAL_LOCATION,
|
121
|
+
val=loc))
|
122
|
+
if row.method:
|
123
|
+
possible_path = os.path.join(self.method_dir, row.method) + ".M\\"
|
124
|
+
method = row.method
|
125
|
+
if os.path.exists(possible_path):
|
126
|
+
method = os.path.join(self.method_dir, row.method)
|
127
|
+
self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=table_register,
|
128
|
+
table_name=table_name,
|
129
|
+
row=row_num,
|
130
|
+
col_name=RegisterFlag.METHOD,
|
131
|
+
val=method))
|
132
|
+
|
133
|
+
if row.num_inj:
|
134
|
+
self.sleepy_send(TableOperation.EDIT_ROW_VAL.value.format(register=table_register,
|
135
|
+
table_name=table_name,
|
136
|
+
row=row_num,
|
137
|
+
col_name=RegisterFlag.NUM_INJ,
|
138
|
+
val=row.num_inj))
|
139
|
+
|
140
|
+
if row.inj_vol:
|
141
|
+
self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=table_register,
|
142
|
+
table_name=table_name,
|
143
|
+
row=row_num,
|
144
|
+
col_name=RegisterFlag.INJ_VOL,
|
145
|
+
val=row.inj_vol))
|
146
|
+
|
147
|
+
if row.inj_source:
|
148
|
+
self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=table_register,
|
149
|
+
table_name=table_name,
|
150
|
+
row=row_num,
|
151
|
+
col_name=RegisterFlag.INJ_SOR,
|
152
|
+
val=row.inj_source.value))
|
153
|
+
|
154
|
+
if row.sample_name:
|
155
|
+
self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=table_register,
|
156
|
+
table_name=table_name,
|
157
|
+
row=row_num,
|
158
|
+
col_name=RegisterFlag.NAME,
|
159
|
+
val=row.sample_name))
|
160
|
+
self.sleepy_send(TableOperation.EDIT_ROW_TEXT.value.format(register=table_register,
|
161
|
+
table_name=table_name,
|
162
|
+
row=row_num,
|
163
|
+
col_name=RegisterFlag.DATA_FILE,
|
164
|
+
val=row.sample_name))
|
165
|
+
if row.sample_type:
|
166
|
+
self.sleepy_send(TableOperation.EDIT_ROW_VAL.value.format(register=table_register,
|
167
|
+
table_name=table_name,
|
168
|
+
row=row_num,
|
169
|
+
col_name=RegisterFlag.SAMPLE_TYPE,
|
170
|
+
val=row.sample_type.value))
|
171
|
+
|
172
|
+
self.send(Command.SAVE_SEQUENCE_CMD)
|
173
|
+
|
174
|
+
def run(self, stall_while_running: bool = True):
|
175
|
+
"""
|
176
|
+
Starts the currently loaded sequence, storing data
|
177
|
+
under the <data_dir>/<sequence table name> folder.
|
178
|
+
Device must be ready.
|
179
|
+
"""
|
180
|
+
timestamp = time.strftime(SEQUENCE_TIME_FORMAT)
|
181
|
+
seq_table = self.load()
|
182
|
+
self.send(Command.RUN_SEQUENCE_CMD.value)
|
183
|
+
|
184
|
+
if self.check_hplc_is_running():
|
185
|
+
folder_name = f"{seq_table.name} {timestamp}"
|
186
|
+
self.data_files.append(SequenceDataFiles(dir=folder_name,
|
187
|
+
sequence_name=seq_table.name))
|
188
|
+
|
189
|
+
if stall_while_running:
|
190
|
+
run_completed = self.check_hplc_done_running(sequence=seq_table)
|
191
|
+
if run_completed.is_ok():
|
192
|
+
self.data_files[-1].dir = run_completed.value
|
193
|
+
else:
|
194
|
+
raise RuntimeError("Run error has occurred.")
|
195
|
+
else:
|
196
|
+
self.data_files[-1].dir = self.fuzzy_match_most_recent_folder(folder_name).ok_value
|
197
|
+
|
198
|
+
def retrieve_recent_data_files(self):
|
199
|
+
sequence_data_files: SequenceDataFiles = self.data_files[-1]
|
200
|
+
return sequence_data_files.dir
|
201
|
+
|
202
|
+
def get_data(self, custom_path:Optional[str] = None ) -> list[AgilentChannelChromatogramData]:
|
203
|
+
parent_dir = self.data_files[-1].dir if not custom_path else custom_path
|
204
|
+
subdirs = [x[0] for x in os.walk(self.data_dir)]
|
205
|
+
potential_folders = sorted(list(filter(lambda d: parent_dir in d, subdirs)))
|
206
|
+
self.data_files[-1].child_dirs = [f for f in potential_folders if
|
207
|
+
parent_dir in f and ".M" not in f and ".D" in f]
|
208
|
+
|
209
|
+
spectra: list[AgilentChannelChromatogramData] = []
|
210
|
+
for row in self.data_files[-1].child_dirs:
|
211
|
+
self.get_spectrum(row)
|
212
|
+
spectra.append(AgilentChannelChromatogramData(**self.spectra))
|
213
|
+
return spectra
|
@@ -0,0 +1,208 @@
|
|
1
|
+
"""
|
2
|
+
Abstract module containing shared logic for Method and Sequence tables.
|
3
|
+
|
4
|
+
Authors: Lucy Hao
|
5
|
+
"""
|
6
|
+
|
7
|
+
import abc
|
8
|
+
import os
|
9
|
+
from typing import Union, Optional
|
10
|
+
|
11
|
+
import polling
|
12
|
+
from result import Result, Ok, Err
|
13
|
+
|
14
|
+
from ...control.controllers.comm import CommunicationController
|
15
|
+
from ...utils.chromatogram import AgilentHPLCChromatogram, AgilentChannelChromatogramData
|
16
|
+
from ...utils.macro import Command, HPLCRunningStatus, Response
|
17
|
+
from ...utils.method_types import MethodTimetable
|
18
|
+
from ...utils.sequence_types import SequenceDataFiles, SequenceTable
|
19
|
+
from ...utils.table_types import Table, TableOperation, RegisterFlag
|
20
|
+
|
21
|
+
|
22
|
+
class TableController(abc.ABC):
|
23
|
+
|
24
|
+
def __init__(self, controller: CommunicationController, src: str, data_dir: str, table: Table):
|
25
|
+
self.controller = controller
|
26
|
+
self.table = table
|
27
|
+
self.table_state : Optional[TableController] = None
|
28
|
+
|
29
|
+
if os.path.isdir(src):
|
30
|
+
self.src: str = src
|
31
|
+
else:
|
32
|
+
raise FileNotFoundError(f"dir: {src} not found.")
|
33
|
+
|
34
|
+
if os.path.isdir(data_dir):
|
35
|
+
self.data_dir: str = data_dir
|
36
|
+
else:
|
37
|
+
raise FileNotFoundError(f"dir: {data_dir} not found.")
|
38
|
+
|
39
|
+
self.spectra: dict[str, AgilentHPLCChromatogram] = {
|
40
|
+
"A": AgilentHPLCChromatogram(self.data_dir),
|
41
|
+
"B": AgilentHPLCChromatogram(self.data_dir),
|
42
|
+
"C": AgilentHPLCChromatogram(self.data_dir),
|
43
|
+
"D": AgilentHPLCChromatogram(self.data_dir),
|
44
|
+
}
|
45
|
+
|
46
|
+
self.data_files: Union[list[SequenceDataFiles], list[str]] = []
|
47
|
+
|
48
|
+
def receive(self) -> Result[Response, str]:
|
49
|
+
for _ in range(10):
|
50
|
+
try:
|
51
|
+
return self.controller.receive()
|
52
|
+
except IndexError:
|
53
|
+
continue
|
54
|
+
return Err("Could not parse response")
|
55
|
+
|
56
|
+
def send(self, cmd: Union[Command, str]):
|
57
|
+
self.controller.send(cmd)
|
58
|
+
|
59
|
+
def sleepy_send(self, cmd: Union[Command, str]):
|
60
|
+
self.controller.sleepy_send(cmd)
|
61
|
+
|
62
|
+
def sleep(self, seconds: int):
|
63
|
+
"""
|
64
|
+
Tells the HPLC to wait for a specified number of seconds.
|
65
|
+
|
66
|
+
:param seconds: number of seconds to wait
|
67
|
+
"""
|
68
|
+
self.send(Command.SLEEP_CMD.value.format(seconds=seconds))
|
69
|
+
|
70
|
+
def get_num(self, row: int, col_name: RegisterFlag) -> float:
|
71
|
+
return self.controller.get_num_val(TableOperation.GET_ROW_VAL.value.format(register=self.table.register,
|
72
|
+
table_name=self.table.name,
|
73
|
+
row=row,
|
74
|
+
col_name=col_name.value))
|
75
|
+
|
76
|
+
def get_text(self, row: int, col_name: RegisterFlag) -> str:
|
77
|
+
return self.controller.get_text_val(TableOperation.GET_ROW_TEXT.value.format(register=self.table.register,
|
78
|
+
table_name=self.table.name,
|
79
|
+
row=row,
|
80
|
+
col_name=col_name.value))
|
81
|
+
|
82
|
+
@abc.abstractmethod
|
83
|
+
def get_row(self, row: int):
|
84
|
+
pass
|
85
|
+
|
86
|
+
def delete_row(self, row: int):
|
87
|
+
self.sleepy_send(TableOperation.DELETE_ROW.value.format(register=self.table.register,
|
88
|
+
table_name=self.table.name,
|
89
|
+
row=row))
|
90
|
+
|
91
|
+
def add_row(self):
|
92
|
+
"""
|
93
|
+
Adds a row to the provided table for currently loaded method or sequence.
|
94
|
+
Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants.
|
95
|
+
You can also provide your own table.
|
96
|
+
|
97
|
+
:param table: the table to add a new row to
|
98
|
+
"""
|
99
|
+
self.sleepy_send(TableOperation.NEW_ROW.value.format(register=self.table.register,
|
100
|
+
table_name=self.table.name))
|
101
|
+
|
102
|
+
def delete_table(self):
|
103
|
+
"""
|
104
|
+
Deletes the table for the current loaded method or sequence.
|
105
|
+
Import either the SEQUENCE_TABLE or METHOD_TIMETABLE from hein_analytical_control.constants.
|
106
|
+
You can also provide your own table.
|
107
|
+
|
108
|
+
:param table: the table to delete
|
109
|
+
"""
|
110
|
+
self.sleepy_send(TableOperation.DELETE_TABLE.value.format(register=self.table.register,
|
111
|
+
table_name=self.table.name))
|
112
|
+
|
113
|
+
def new_table(self):
|
114
|
+
"""
|
115
|
+
Creates the table for the currently loaded method or sequence. Import either the SEQUENCE_TABLE or
|
116
|
+
METHOD_TIMETABLE from hein_analytical_control.constants. You can also provide your own table.
|
117
|
+
|
118
|
+
:param table: the table to create
|
119
|
+
"""
|
120
|
+
self.send(TableOperation.CREATE_TABLE.value.format(register=self.table.register,
|
121
|
+
table_name=self.table.name))
|
122
|
+
|
123
|
+
def get_num_rows(self) -> Result[Response, str]:
|
124
|
+
self.send(TableOperation.GET_NUM_ROWS.value.format(register=self.table.register,
|
125
|
+
table_name=self.table.name,
|
126
|
+
col_name=RegisterFlag.NUM_ROWS))
|
127
|
+
self.send(Command.GET_ROWS_CMD.value.format(register=self.table.register,
|
128
|
+
table_name=self.table.name,
|
129
|
+
col_name=RegisterFlag.NUM_ROWS))
|
130
|
+
res = self.controller.receive()
|
131
|
+
|
132
|
+
if res.is_ok():
|
133
|
+
self.send("Sleep 0.1")
|
134
|
+
self.send('Print Rows')
|
135
|
+
return res
|
136
|
+
else:
|
137
|
+
return Err("No rows could be read.")
|
138
|
+
|
139
|
+
def check_hplc_is_running(self) -> bool:
|
140
|
+
started_running = polling.poll(
|
141
|
+
lambda: isinstance(self.controller.get_status(), HPLCRunningStatus),
|
142
|
+
step=5,
|
143
|
+
max_tries=100)
|
144
|
+
return started_running
|
145
|
+
|
146
|
+
def check_hplc_done_running(self,
|
147
|
+
method: Optional[MethodTimetable] = None,
|
148
|
+
sequence: Optional[SequenceTable] = None) -> Result[str, str]:
|
149
|
+
"""
|
150
|
+
Checks if ChemStation has finished running and can read data back
|
151
|
+
|
152
|
+
: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
|
153
|
+
:return: Return True if data can be read back, else False.
|
154
|
+
"""
|
155
|
+
timeout = 10 * 60
|
156
|
+
if method:
|
157
|
+
timeout = ((method.first_row.maximum_run_time + 5) * 60)
|
158
|
+
if sequence:
|
159
|
+
timeout *= len(sequence.rows)
|
160
|
+
|
161
|
+
most_recent_folder = self.retrieve_recent_data_files()
|
162
|
+
finished_run = polling.poll(
|
163
|
+
lambda: self.controller.check_if_running(),
|
164
|
+
timeout=timeout,
|
165
|
+
step=18)
|
166
|
+
|
167
|
+
check_folder = self.fuzzy_match_most_recent_folder(most_recent_folder)
|
168
|
+
if check_folder.is_ok() and finished_run:
|
169
|
+
return check_folder
|
170
|
+
elif check_folder.is_ok():
|
171
|
+
finished_run = polling.poll(
|
172
|
+
lambda: self.controller.check_if_running(),
|
173
|
+
timeout=timeout,
|
174
|
+
step=12)
|
175
|
+
if finished_run:
|
176
|
+
return check_folder
|
177
|
+
else:
|
178
|
+
return Err("Run did not complete as expected")
|
179
|
+
|
180
|
+
def fuzzy_match_most_recent_folder(self, most_recent_folder) -> Result[str, str]:
|
181
|
+
if os.path.exists(most_recent_folder):
|
182
|
+
return Ok(most_recent_folder)
|
183
|
+
|
184
|
+
subdirs = [x[0] for x in os.walk(self.data_dir)]
|
185
|
+
potential_folders = sorted(list(filter(lambda d: most_recent_folder in d, subdirs)))
|
186
|
+
parent_dirs = []
|
187
|
+
for folder in potential_folders:
|
188
|
+
path = os.path.normpath(folder)
|
189
|
+
split_folder = path.split(os.sep)
|
190
|
+
if most_recent_folder in split_folder[-1]:
|
191
|
+
parent_dirs.append(folder)
|
192
|
+
parent_dir = sorted(parent_dirs, reverse=True)[0]
|
193
|
+
return Ok(parent_dir)
|
194
|
+
|
195
|
+
@abc.abstractmethod
|
196
|
+
def retrieve_recent_data_files(self):
|
197
|
+
pass
|
198
|
+
|
199
|
+
@abc.abstractmethod
|
200
|
+
def get_data(self) -> Union[list[AgilentChannelChromatogramData], AgilentChannelChromatogramData]:
|
201
|
+
pass
|
202
|
+
|
203
|
+
def get_spectrum(self, data_file: str):
|
204
|
+
"""
|
205
|
+
Load chromatogram for any channel in spectra dictionary.
|
206
|
+
"""
|
207
|
+
for channel, spec in self.spectra.items():
|
208
|
+
spec.load_spectrum(data_path=data_file, channel=channel)
|
pychemstation/control/hplc.py
CHANGED
@@ -6,9 +6,8 @@ Authors: Lucy Hao
|
|
6
6
|
|
7
7
|
from typing import Union, Optional
|
8
8
|
|
9
|
-
from ..control import CommunicationController
|
10
|
-
from ..
|
11
|
-
from ..utils.chromatogram import AgilentHPLCChromatogram
|
9
|
+
from ..control.controllers import MethodController, SequenceController, CommunicationController
|
10
|
+
from ..utils.chromatogram import AgilentHPLCChromatogram, AgilentChannelChromatogramData
|
12
11
|
from ..utils.macro import Command, HPLCRunningStatus, HPLCAvailStatus, HPLCErrorStatus, Response
|
13
12
|
from ..utils.method_types import MethodTimetable
|
14
13
|
from ..utils.sequence_types import SequenceTable, SequenceEntry
|
@@ -65,8 +64,8 @@ class HPLCController:
|
|
65
64
|
only 'General-Poroshell' is needed.
|
66
65
|
|
67
66
|
:param method_name: any available method in Chemstation method directory
|
68
|
-
:
|
69
|
-
:
|
67
|
+
:raises IndexError: Response did not have expected format. Try again.
|
68
|
+
:raises AssertionError: The desired method is not selected. Try again.
|
70
69
|
"""
|
71
70
|
self.method_controller.switch(method_name)
|
72
71
|
|
@@ -127,13 +126,13 @@ class HPLCController:
|
|
127
126
|
"""
|
128
127
|
self.sequence_controller.edit_row(row, num)
|
129
128
|
|
130
|
-
def get_last_run_method_data(self) ->
|
129
|
+
def get_last_run_method_data(self) -> AgilentChannelChromatogramData:
|
131
130
|
"""
|
132
131
|
Returns the last run method data.
|
133
132
|
"""
|
134
133
|
return self.method_controller.get_data()
|
135
134
|
|
136
|
-
def get_last_run_sequence_data(self) -> list[
|
135
|
+
def get_last_run_sequence_data(self) -> list[AgilentChannelChromatogramData]:
|
137
136
|
"""
|
138
137
|
Returns data for all rows in the last run sequence data.
|
139
138
|
"""
|
@@ -12,8 +12,8 @@ from typing import Union, Optional
|
|
12
12
|
import polling
|
13
13
|
from result import Result, Ok, Err
|
14
14
|
|
15
|
-
from ...control import CommunicationController
|
16
|
-
from ...utils.chromatogram import AgilentHPLCChromatogram
|
15
|
+
from ...control.controllers.comm import CommunicationController
|
16
|
+
from ...utils.chromatogram import AgilentHPLCChromatogram, AgilentChannelChromatogramData
|
17
17
|
from ...utils.macro import Command, HPLCRunningStatus, Response
|
18
18
|
from ...utils.method_types import MethodTimetable
|
19
19
|
from ...utils.sequence_types import SequenceDataFiles, SequenceTable
|
@@ -139,8 +139,8 @@ class TableController(abc.ABC):
|
|
139
139
|
def check_hplc_is_running(self) -> bool:
|
140
140
|
started_running = polling.poll(
|
141
141
|
lambda: isinstance(self.controller.get_status(), HPLCRunningStatus),
|
142
|
-
step=
|
143
|
-
max_tries=
|
142
|
+
step=5,
|
143
|
+
max_tries=100)
|
144
144
|
return started_running
|
145
145
|
|
146
146
|
def check_hplc_done_running(self,
|
@@ -162,32 +162,35 @@ class TableController(abc.ABC):
|
|
162
162
|
finished_run = polling.poll(
|
163
163
|
lambda: self.controller.check_if_running(),
|
164
164
|
timeout=timeout,
|
165
|
-
step=
|
165
|
+
step=12
|
166
166
|
)
|
167
167
|
|
168
168
|
if finished_run:
|
169
169
|
if os.path.exists(most_recent_folder):
|
170
170
|
return Ok(most_recent_folder)
|
171
171
|
else:
|
172
|
-
|
173
|
-
potential_folders = sorted(list(filter(lambda d: most_recent_folder in d, subdirs)))
|
174
|
-
parent_dirs = []
|
175
|
-
for folder in potential_folders:
|
176
|
-
path = os.path.normpath(folder)
|
177
|
-
split_folder = path.split(os.sep)
|
178
|
-
if most_recent_folder in split_folder[-1]:
|
179
|
-
parent_dirs.append(folder)
|
180
|
-
parent_dir = sorted(parent_dirs, reverse=True)[0]
|
181
|
-
return Ok(parent_dir)
|
172
|
+
return self.fuzzy_match_most_recent_folder(most_recent_folder)
|
182
173
|
else:
|
183
174
|
return Err("Run did not complete as expected")
|
184
175
|
|
176
|
+
def fuzzy_match_most_recent_folder(self, most_recent_folder) -> Result[str, str]:
|
177
|
+
subdirs = [x[0] for x in os.walk(self.data_dir)]
|
178
|
+
potential_folders = sorted(list(filter(lambda d: most_recent_folder in d, subdirs)))
|
179
|
+
parent_dirs = []
|
180
|
+
for folder in potential_folders:
|
181
|
+
path = os.path.normpath(folder)
|
182
|
+
split_folder = path.split(os.sep)
|
183
|
+
if most_recent_folder in split_folder[-1]:
|
184
|
+
parent_dirs.append(folder)
|
185
|
+
parent_dir = sorted(parent_dirs, reverse=True)[0]
|
186
|
+
return Ok(parent_dir)
|
187
|
+
|
185
188
|
@abc.abstractmethod
|
186
189
|
def retrieve_recent_data_files(self):
|
187
190
|
pass
|
188
191
|
|
189
192
|
@abc.abstractmethod
|
190
|
-
def get_data(self) ->
|
193
|
+
def get_data(self) -> Union[list[AgilentChannelChromatogramData], AgilentChannelChromatogramData]:
|
191
194
|
pass
|
192
195
|
|
193
196
|
def get_spectrum(self, data_file: str):
|
@@ -1,17 +1,14 @@
|
|
1
1
|
"""Module for HPLC chromatogram data loading and manipulating"""
|
2
2
|
|
3
3
|
import os
|
4
|
-
import logging
|
5
4
|
import time
|
5
|
+
from dataclasses import dataclass
|
6
6
|
|
7
7
|
import numpy as np
|
8
8
|
|
9
9
|
from .parsing import CHFile
|
10
10
|
from ..analysis import AbstractSpectrum
|
11
11
|
|
12
|
-
# Chemstation data path
|
13
|
-
DATA_DIR = r"C:\Chem32\1\Data"
|
14
|
-
|
15
12
|
# standard filenames for spectral data
|
16
13
|
CHANNELS = {"A": "01", "B": "02", "C": "03", "D": "04"}
|
17
14
|
|
@@ -19,7 +16,7 @@ ACQUISITION_PARAMETERS = "acq.txt"
|
|
19
16
|
|
20
17
|
# format used in acquisition parameters
|
21
18
|
TIME_FORMAT = "%Y-%m-%d %H-%M-%S"
|
22
|
-
SEQUENCE_TIME_FORMAT = "%Y-%m-%d %H"
|
19
|
+
SEQUENCE_TIME_FORMAT = "%Y-%m-%d %H-%M"
|
23
20
|
|
24
21
|
|
25
22
|
class AgilentHPLCChromatogram(AbstractSpectrum):
|
@@ -105,26 +102,10 @@ class AgilentHPLCChromatogram(AbstractSpectrum):
|
|
105
102
|
np.savez_compressed(npz_file, times=data.times, values=data.values)
|
106
103
|
return np.array(data.times), np.array(data.values)
|
107
104
|
|
108
|
-
def extract_peakarea(self, experiment_dir: str):
|
109
|
-
"""
|
110
|
-
Reads processed data from Chemstation report files.
|
111
105
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
# return data
|
119
|
-
pass
|
120
|
-
|
121
|
-
def default_processing(self):
|
122
|
-
"""
|
123
|
-
Processes the chromatogram in place.
|
124
|
-
"""
|
125
|
-
# trim first 5 min and last 3 min of run
|
126
|
-
self.trim(5, 25)
|
127
|
-
# parameters found to work best for chromatogram data
|
128
|
-
self.correct_baseline(lmbd=1e5, p=0.0001, n_iter=10)
|
129
|
-
# get all peaks in processed chromatogram
|
130
|
-
self.find_peaks()
|
106
|
+
@dataclass
|
107
|
+
class AgilentChannelChromatogramData:
|
108
|
+
A: AgilentHPLCChromatogram
|
109
|
+
B: AgilentHPLCChromatogram
|
110
|
+
C: AgilentHPLCChromatogram
|
111
|
+
D: AgilentHPLCChromatogram
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: pychemstation
|
3
|
-
Version: 0.5.
|
3
|
+
Version: 0.5.6.dev1
|
4
4
|
Summary: Library to interact with Chemstation software, primarily used in Hein lab
|
5
5
|
Home-page: https://gitlab.com/heingroup/device-api/pychemstation
|
6
6
|
Author: Lucy Hao
|
@@ -41,7 +41,7 @@ pip install pychemstation
|
|
41
41
|
where you will put your
|
42
42
|
MACRO file(s).
|
43
43
|
3. Download the [
|
44
|
-
`hplc_talk.mac`](https://
|
44
|
+
`hplc_talk.mac`](https://gitlab.com/heingroup/device-api/pychemstation/-/blob/main/tests/hplc_talk.mac).
|
45
45
|
- On line 69, change the path name up to `\cmd` and `\reply`. For instance, you should have:
|
46
46
|
`MonitorFile "[my path]\cmd", "[my path]\reply"`
|
47
47
|
- and then add this file to the folder from the previous step.
|
@@ -78,13 +78,12 @@ hplc_controller = HPLCController(data_dir=DATA_DIR,
|
|
78
78
|
hplc_controller.preprun()
|
79
79
|
hplc_controller.switch_method(method_name=DEFAULT_METHOD)
|
80
80
|
hplc_controller.run_method(experiment_name="Run 10")
|
81
|
-
|
81
|
+
chrom = hplc_controller.get_last_run_method_data()
|
82
82
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
chromatogram_data.to_csv("Run 10.csv", index=False)
|
83
|
+
# afterwards, save, analyze or plot the data!
|
84
|
+
values = {"x": chrom.A.x, "y": chrom.A.y}
|
85
|
+
chromatogram_data = pd.DataFrame.from_dict(values)
|
86
|
+
chromatogram_data.to_csv("Run 10.csv", index=False)
|
88
87
|
```
|
89
88
|
|
90
89
|
## Adding your own MACROs
|
@@ -103,3 +102,6 @@ Lucy Hao
|
|
103
102
|
|
104
103
|
- Adapted from [**AnalyticalLabware**](https://github.com/croningp/analyticallabware), created by members in the Cronin
|
105
104
|
Group. Copyright © Cronin Group, used under the [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/) license.
|
105
|
+
- Adapted from the [MACROS](https://github.com/Bourne-Group/HPLCMethodOptimisationGUI)
|
106
|
+
used in [**Operator-free HPLC automated method development guided by Bayesian optimization**](https://pubs.rsc.org/en/content/articlelanding/2024/dd/d4dd00062e),
|
107
|
+
created by members in the Bourne Group. Copyright © Bourne Group, used under the [MIT](https://opensource.org/license/mit) license.
|
@@ -3,18 +3,23 @@ pychemstation/analysis/__init__.py,sha256=EWoU47iyn9xGS-b44zK9eq50bSjOV4AC5dvt42
|
|
3
3
|
pychemstation/analysis/base_spectrum.py,sha256=FBvwzLtF9mdqW7f8ETY9G4cpfJ-SzbiSkZq9EtXcSXo,17045
|
4
4
|
pychemstation/analysis/spec_utils.py,sha256=8NZMV0dtfxZLARjWzM5ks0tgEYQv_SKUiZzba2IoKgw,10505
|
5
5
|
pychemstation/analysis/utils.py,sha256=ISupAOb_yqA4_DZRK9v18UL-XjUQccAicIJKb1VMnGg,2055
|
6
|
-
pychemstation/control/__init__.py,sha256=
|
6
|
+
pychemstation/control/__init__.py,sha256=4xTy8X-mkn_PPZKr7w9rnj1wZhtmTesbQptPhpYmKXs,64
|
7
7
|
pychemstation/control/comm.py,sha256=u44g1hTluQ0yUG93Un-QAshScoDpgYRrZfFTgweP5tY,7386
|
8
|
-
pychemstation/control/hplc.py,sha256=
|
8
|
+
pychemstation/control/hplc.py,sha256=L1-cBQ7-thuITx1Mqq3lYXo832nEQtRJEX7nlLvRYrg,6732
|
9
|
+
pychemstation/control/controllers/__init__.py,sha256=di3ytLIK-35XC_THw4IjNaOtCUTe7GuEOFb-obmREw4,166
|
10
|
+
pychemstation/control/controllers/comm.py,sha256=iltKMNfdp_8INA5Vss9asI2LxJam_Ro3YWBHBQFr4t0,7353
|
11
|
+
pychemstation/control/controllers/method.py,sha256=GNGvGUF9LUCcHAe37SIdnQQ_RzwgnU2ba1oWLXnOp8w,12816
|
12
|
+
pychemstation/control/controllers/sequence.py,sha256=f4ZhzkM2hY8xk8dUyiCpmZ5fwU4ev2-CNSbyFeFVe_w,10963
|
13
|
+
pychemstation/control/controllers/table_controller.py,sha256=DnusCG5ySR8gA-oe3K_EJfAPlYhmW33epiCi4YVUKJg,8648
|
9
14
|
pychemstation/control/table/__init__.py,sha256=RgMN4uIWHdNUHpGRBWdzmzAbk7XEKl6Y-qtqWCxzSZU,124
|
10
15
|
pychemstation/control/table/method.py,sha256=THVoGomSXff_CTU3eAYme0BYwkPzab5UgZKsiZ29QSk,12196
|
11
16
|
pychemstation/control/table/sequence.py,sha256=Eri52AnbE3BGthfrRSvYKYciquUzvHKo0lYUTySYYE8,10542
|
12
|
-
pychemstation/control/table/table_controller.py,sha256=
|
17
|
+
pychemstation/control/table/table_controller.py,sha256=HVNYUXqtyFTAvb67fa3RO5RHgmBTFMsYRHKpiXdYcfs,8313
|
13
18
|
pychemstation/generated/__init__.py,sha256=GAoZFAYbPVEJDkcOw3e1rgOqd7TCW0HyKNPM8OMehMg,1005
|
14
19
|
pychemstation/generated/dad_method.py,sha256=0W8Z5WDtF5jpIcudMqb7XrkTnR2EGg_QOCsHRFQ0rmM,8402
|
15
20
|
pychemstation/generated/pump_method.py,sha256=sUhE2Oo00nzVcoONtq3EMWsN4wLSryXbG8f3EeViWKg,12174
|
16
21
|
pychemstation/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
17
|
-
pychemstation/utils/chromatogram.py,sha256=
|
22
|
+
pychemstation/utils/chromatogram.py,sha256=RiKWQG2FIlDzddhgABV-WIJg_BsCTL_wPfyYGikyxoM,3097
|
18
23
|
pychemstation/utils/macro.py,sha256=BAIcE_dppNffwrSqGq8gh0ccE9YAJfQFQZHXJgA1WtA,2586
|
19
24
|
pychemstation/utils/method_types.py,sha256=YngbyHg96JSFnvhm5Zd7wJvLTQPPQsLbvbyz3HlGLYY,862
|
20
25
|
pychemstation/utils/parsing.py,sha256=bnFIsZZwFy9NKzVUf517yN-ogzQbm0hp_aho3KUD6Is,9317
|
@@ -23,12 +28,12 @@ pychemstation/utils/table_types.py,sha256=cN51Ry2pammDdk85cabVH3qkchjKKIzZfAH87P
|
|
23
28
|
pychemstation/utils/tray_types.py,sha256=UUDED-IAf-8FmPVZezuWSiIQE_HgiZQMV2sTqu4oZw8,177
|
24
29
|
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
25
30
|
tests/constants.py,sha256=lLwT_QaVriivL5llkul6RxXxPriwUp1E-Y3G9fejvbo,2092
|
26
|
-
tests/test_comb.py,sha256=
|
31
|
+
tests/test_comb.py,sha256=CF6nSxUMBJeUjA-IfgPcBGV5oIG4aH1-RA26oIATa-s,5640
|
27
32
|
tests/test_comm.py,sha256=1ZZd0UrIBOKe91wzA-XI-gSRgXmId9mLWYSMeche82Y,2973
|
28
33
|
tests/test_method.py,sha256=uCPpZVYKPz1CNWwhmBo_8TH0ku2V0ZpDZJj3f8iINB4,2440
|
29
34
|
tests/test_sequence.py,sha256=yIQGhUTehtHz6D1ai5W6AlP0zes2icF0VdQ0IGJ2CbQ,4901
|
30
|
-
pychemstation-0.5.
|
31
|
-
pychemstation-0.5.
|
32
|
-
pychemstation-0.5.
|
33
|
-
pychemstation-0.5.
|
34
|
-
pychemstation-0.5.
|
35
|
+
pychemstation-0.5.6.dev1.dist-info/LICENSE,sha256=9bdF75gIf1MecZ7oymqWgJREVz7McXPG-mjqrTmzzD8,18658
|
36
|
+
pychemstation-0.5.6.dev1.dist-info/METADATA,sha256=7Pj5MObTqRmX3B19nhA70BzBRyHSA7bDku7O1-D89lE,4312
|
37
|
+
pychemstation-0.5.6.dev1.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
|
38
|
+
pychemstation-0.5.6.dev1.dist-info/top_level.txt,sha256=zXfKu_4nYWwPHo3OsuhshMNC3SPkcoTGCyODjURaghY,20
|
39
|
+
pychemstation-0.5.6.dev1.dist-info/RECORD,,
|
tests/test_comb.py
CHANGED
@@ -28,14 +28,14 @@ class TestCombinations(unittest.TestCase):
|
|
28
28
|
first_row=HPLCMethodParams(
|
29
29
|
organic_modifier=5,
|
30
30
|
flow=0.65,
|
31
|
-
maximum_run_time=
|
31
|
+
maximum_run_time=1),
|
32
32
|
subsequent_rows=[
|
33
33
|
TimeTableEntry(
|
34
34
|
start_time=0.10,
|
35
35
|
organic_modifer=5,
|
36
36
|
flow=0.34),
|
37
37
|
TimeTableEntry(
|
38
|
-
start_time=
|
38
|
+
start_time=0.5,
|
39
39
|
organic_modifer=98,
|
40
40
|
flow=0.55)])
|
41
41
|
self.hplc_controller.edit_method(rand_method, save=True)
|
File without changes
|
File without changes
|
File without changes
|