pytrms 0.9.0__py3-none-any.whl → 0.9.2__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.
- pytrms/__init__.py +8 -7
- pytrms/_base/mqttclient.py +20 -7
- pytrms/clients/__init__.py +2 -2
- pytrms/clients/db_api.py +80 -83
- pytrms/clients/ioniclient.py +5 -21
- pytrms/clients/modbus.py +44 -11
- pytrms/clients/mqtt.py +40 -16
- pytrms/clients/ssevent.py +55 -55
- pytrms/compose/composition.py +118 -23
- pytrms/helpers.py +117 -3
- pytrms/instrument.py +22 -17
- pytrms/measurement.py +10 -8
- pytrms/peaktable.py +7 -5
- pytrms/readers/ionitof_reader.py +7 -16
- {pytrms-0.9.0.dist-info → pytrms-0.9.2.dist-info}/METADATA +3 -3
- pytrms-0.9.2.dist-info/RECORD +27 -0
- pytrms/clients/dirigent.py +0 -169
- pytrms/tracebuffer.py +0 -108
- pytrms-0.9.0.dist-info/RECORD +0 -29
- {pytrms-0.9.0.dist-info → pytrms-0.9.2.dist-info}/LICENSE +0 -0
- {pytrms-0.9.0.dist-info → pytrms-0.9.2.dist-info}/WHEEL +0 -0
pytrms/clients/ssevent.py
CHANGED
|
@@ -1,82 +1,82 @@
|
|
|
1
1
|
import re
|
|
2
|
-
import
|
|
2
|
+
from collections import namedtuple
|
|
3
3
|
from collections.abc import Iterable
|
|
4
4
|
|
|
5
5
|
import requests
|
|
6
6
|
|
|
7
|
-
from . import
|
|
7
|
+
from . import _logging
|
|
8
8
|
|
|
9
|
-
log =
|
|
9
|
+
log = _logging.getLogger(__name__)
|
|
10
10
|
|
|
11
|
+
_event_rv = namedtuple('ssevent', ['event', 'data'])
|
|
11
12
|
|
|
12
13
|
class SSEventListener(Iterable):
|
|
13
14
|
|
|
14
|
-
def __init__(self, endpoint=''):
|
|
15
|
-
if not endpoint:
|
|
16
|
-
endpoint = ionitof_url + '/api/timing/stream'
|
|
17
|
-
self.endpoint = endpoint
|
|
18
|
-
self._response = None
|
|
19
|
-
self.subscriptions = set()
|
|
20
|
-
|
|
21
|
-
def subscribe(self, event='cycle'):
|
|
22
|
-
"""Listen for events matching the given string or regular expression.
|
|
23
|
-
|
|
24
|
-
This will already match if the string is contained in the event-topic.
|
|
25
|
-
"""
|
|
26
|
-
if self._response is None:
|
|
27
|
-
r = requests.get(self.endpoint, headers={'accept': 'text/event-stream'}, stream=True)
|
|
28
|
-
if not r.ok:
|
|
29
|
-
log.error(f"no connection to {self.endpoint} (got [{r.status_code}])")
|
|
30
|
-
r.raise_for_status()
|
|
31
|
-
|
|
32
|
-
self._response = r
|
|
33
|
-
|
|
34
|
-
regex = re.compile('.*' + event + '.*') # be rather loose with matching..
|
|
35
|
-
self.subscriptions.add(regex)
|
|
36
|
-
|
|
37
|
-
def unsubscribe(self, event='cycle'):
|
|
38
|
-
self.subscriptions.remove(event)
|
|
39
|
-
if not len(self.subscriptions):
|
|
40
|
-
log.debug(f"closing connection to {self.endpoint}")
|
|
41
|
-
self._response.close()
|
|
42
|
-
self._response = None
|
|
43
|
-
|
|
44
15
|
@staticmethod
|
|
45
|
-
def
|
|
16
|
+
def _line_stream(response):
|
|
46
17
|
# Note: using .iter_content() seems to yield results faster than .iter_lines()
|
|
47
18
|
line = ''
|
|
48
19
|
for bite in response.iter_content(chunk_size=1, decode_unicode=True):
|
|
49
20
|
line += bite
|
|
50
21
|
if bite == '\n':
|
|
51
|
-
yield line
|
|
22
|
+
yield line.strip()
|
|
52
23
|
line = ''
|
|
53
24
|
|
|
54
|
-
def
|
|
55
|
-
|
|
56
|
-
|
|
25
|
+
def __init__(self, event_re=None, host_url='http://127.0.0.1:5066',
|
|
26
|
+
endpoint='/api/events', session=None):
|
|
27
|
+
self.uri = host_url + endpoint
|
|
28
|
+
if session is not None:
|
|
29
|
+
self._get = session.get
|
|
30
|
+
else:
|
|
31
|
+
self._get = requests.get
|
|
32
|
+
self._connect_response = None
|
|
33
|
+
self.subscriptions = set()
|
|
34
|
+
if event_re is not None:
|
|
35
|
+
self.subscribe(event_re)
|
|
36
|
+
|
|
37
|
+
def subscribe(self, event_re):
|
|
38
|
+
"""Listen for events matching the given string or regular expression."""
|
|
39
|
+
self.subscriptions.add(re.compile(event_re))
|
|
40
|
+
if self._connect_response is None:
|
|
41
|
+
r = self._get(self.uri, headers={'accept': 'text/event-stream'}, stream=True)
|
|
42
|
+
if not r.ok:
|
|
43
|
+
log.error(f"no connection to {self.uri} (got [{r.status_code}])")
|
|
44
|
+
r.raise_for_status()
|
|
45
|
+
|
|
46
|
+
self._connect_response = r
|
|
47
|
+
|
|
48
|
+
def unsubscribe(self, event_re):
|
|
49
|
+
"""Stop listening for certain events."""
|
|
50
|
+
self.subscriptions.remove(re.compile(event_re))
|
|
51
|
+
if not len(self.subscriptions):
|
|
52
|
+
log.debug(f"closing connection to {self.uri}")
|
|
53
|
+
self._connect_response.close()
|
|
54
|
+
self._connect_response = None
|
|
57
55
|
|
|
58
56
|
def __iter__(self):
|
|
59
|
-
if self.
|
|
57
|
+
if self._connect_response is None:
|
|
60
58
|
raise Exception("call .subscribe() first to listen for events")
|
|
61
59
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
event = msg = ''
|
|
61
|
+
for line in self._line_stream(self._connect_response): # blocks...
|
|
62
|
+
if not line:
|
|
63
|
+
# an empty line concludes an event
|
|
64
|
+
if event and any(re.match(sub, event) for sub in self.subscriptions):
|
|
65
|
+
yield _event_rv(event, msg)
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if not any(sub.match(self.event) for sub in self.subscriptions):
|
|
72
|
-
log.debug(f"skipping event <{msg}>")
|
|
73
|
-
continue
|
|
67
|
+
# Note: any further empty lines are ignored (may be used as keep-alive),
|
|
68
|
+
# but in either case clear event and msg to rearm for the next event:
|
|
69
|
+
event = msg = ''
|
|
70
|
+
continue
|
|
74
71
|
|
|
72
|
+
key, val = line.split(':', maxsplit=1)
|
|
73
|
+
if not key:
|
|
74
|
+
# this is a comment, starting with a colon ':' ...
|
|
75
|
+
log.log(_logging.TRACE, "sse:" + val)
|
|
76
|
+
elif key == 'event':
|
|
77
|
+
event = val.lstrip()
|
|
75
78
|
elif key == 'data':
|
|
76
|
-
|
|
77
|
-
|
|
79
|
+
msg += val.lstrip()
|
|
78
80
|
else:
|
|
79
|
-
log.warning(f"
|
|
80
|
-
continue
|
|
81
|
-
|
|
81
|
+
log.warning(f"unknown SSE-key <{key}> in stream")
|
|
82
82
|
|
pytrms/compose/composition.py
CHANGED
|
@@ -4,6 +4,7 @@ import itertools
|
|
|
4
4
|
import logging
|
|
5
5
|
from collections.abc import Iterable
|
|
6
6
|
from collections import namedtuple
|
|
7
|
+
from itertools import tee
|
|
7
8
|
from functools import wraps
|
|
8
9
|
|
|
9
10
|
__all__ = ['Step', 'Composition']
|
|
@@ -30,14 +31,19 @@ class Step:
|
|
|
30
31
|
>>> step.set_values
|
|
31
32
|
{'DPS_Udrift': 500}
|
|
32
33
|
|
|
33
|
-
Note, that no Automation-numbers can be defined in the step
|
|
34
|
+
Note, that no Automation-numbers can be defined in the step..
|
|
34
35
|
>>> Step("H50", {'AUTO_UseMean': 0}, 10, start_delay=2)
|
|
35
36
|
Traceback (most recent call last):
|
|
36
37
|
...
|
|
37
38
|
AssertionError: Automation numbers cannot be defined
|
|
38
39
|
|
|
39
|
-
''
|
|
40
|
+
..and neither can a 'OP_Mode' alongside anything else:
|
|
41
|
+
>>> Step("Odd2", {'DPS_Udrift': 345, 'OP_Mode': 2}, 10, start_delay=2)
|
|
42
|
+
Traceback (most recent call last):
|
|
43
|
+
...
|
|
44
|
+
AssertionError: if 'OP_Mode' is specified, nothing else can be
|
|
40
45
|
|
|
46
|
+
'''
|
|
41
47
|
protected_keys = ['AME_RunNumber', 'AME_StepNumber', 'AUTO_UseMean']
|
|
42
48
|
|
|
43
49
|
def __init__(self, name, set_values, duration, start_delay):
|
|
@@ -51,8 +57,13 @@ class Step:
|
|
|
51
57
|
assert self.start_delay >= 0
|
|
52
58
|
assert self.start_delay < self.duration
|
|
53
59
|
|
|
54
|
-
for key in self.set_values
|
|
60
|
+
for key in self.set_values:
|
|
55
61
|
assert key not in Step.protected_keys, "Automation numbers cannot be defined"
|
|
62
|
+
if 'OP_Mode' in self.set_values:
|
|
63
|
+
assert len(self.set_values) == 1, "if 'OP_Mode' is specified, nothing else can be"
|
|
64
|
+
|
|
65
|
+
def __repr__(self):
|
|
66
|
+
return f"{self.name}: ({self.start_delay}/{self.duration}) sec ~> {self.set_values}"
|
|
56
67
|
|
|
57
68
|
|
|
58
69
|
class Composition(Iterable):
|
|
@@ -77,15 +88,34 @@ class Composition(Iterable):
|
|
|
77
88
|
>>> list(co.sequence())
|
|
78
89
|
[(8, {'Eins': 1}), (18, {'Zwei': 2})]
|
|
79
90
|
|
|
80
|
-
...with an action-number at the start...
|
|
91
|
+
...with an action-number at the start (note, that AME-numbers are 1 cycle ahead)...
|
|
81
92
|
>>> co.start_action = 7
|
|
82
93
|
>>> list(co.sequence())
|
|
83
|
-
[(
|
|
94
|
+
[(9, {'AME_ActionNumber': 7}), (8, {'Eins': 1}), (18, {'Zwei': 2})]
|
|
84
95
|
|
|
85
96
|
...or with automation numbers, where the 'start_delay' comes into play:
|
|
86
97
|
>>> co.generate_automation = True
|
|
87
|
-
>>>
|
|
88
|
-
|
|
98
|
+
>>> seq = co.sequence()
|
|
99
|
+
>>> next(seq)
|
|
100
|
+
(9, {'AME_ActionNumber': 7})
|
|
101
|
+
|
|
102
|
+
>>> next(seq)
|
|
103
|
+
(8, {'Eins': 1})
|
|
104
|
+
|
|
105
|
+
>>> next(seq)
|
|
106
|
+
(9, {'AME_StepNumber': 1, 'AME_RunNumber': 1, 'AUTO_UseMean': 0})
|
|
107
|
+
|
|
108
|
+
>>> next(seq)
|
|
109
|
+
(11, {'AUTO_UseMean': 1})
|
|
110
|
+
|
|
111
|
+
>>> next(seq)
|
|
112
|
+
(18, {'Zwei': 2})
|
|
113
|
+
|
|
114
|
+
>>> next(seq)
|
|
115
|
+
(19, {'AME_StepNumber': 2, 'AUTO_UseMean': 0})
|
|
116
|
+
|
|
117
|
+
>>> next(seq)
|
|
118
|
+
(22, {'AUTO_UseMean': 1})
|
|
89
119
|
|
|
90
120
|
'''
|
|
91
121
|
|
|
@@ -122,6 +152,67 @@ class Composition(Iterable):
|
|
|
122
152
|
def dump(self, ofstream):
|
|
123
153
|
json.dump(self, ofstream, indent=2, default=vars)
|
|
124
154
|
|
|
155
|
+
def translate_op_modes(self, preset_items, check=True):
|
|
156
|
+
'''Given the `preset_items` (from a presets-file), compile a list of set_values.
|
|
157
|
+
|
|
158
|
+
>>> presets = {}
|
|
159
|
+
>>> presets[0] = ('H3O+', {('Drift', 'Global_System.DriftPressureSet', 'FLOAT'): 2.6})
|
|
160
|
+
>>> presets[2] = ('O3+', {('T-Drift[°C]', 'Global_Temperatures.TempsSet[0]', 'FLOAT'): 75.0})
|
|
161
|
+
|
|
162
|
+
next, define a couple of Steps that use the presets (a.k.a. 'OP_Mode'):
|
|
163
|
+
>>> steps = []
|
|
164
|
+
>>> steps.append(Step('uno', {'OP_Mode': 0}, 10, 2)) # set p-Drift by OP_Mode
|
|
165
|
+
>>> steps.append(Step('due', {'Udrift': 420.0, 'T-Drift': 81.0}, 10, 2))
|
|
166
|
+
>>> steps.append(Step('tre', {'OP_Mode': 2}, 10, 2)) # set T-Drift by OP_Mode
|
|
167
|
+
|
|
168
|
+
the Composition of these steps will translate to the output underneath.
|
|
169
|
+
note, that the set-value for Pdrift_Ctrl is carried along with each step:
|
|
170
|
+
>>> co = Composition(steps)
|
|
171
|
+
>>> co.translate_op_modes(presets, check=False)
|
|
172
|
+
[{'DPS_Pdrift_Ctrl_Val': 2.6}, {'Udrift': 420.0, 'T-Drift': 81.0, 'DPS_Pdrift_Ctrl_Val': 2.6}, {'T-Drift': 75.0, 'DPS_Pdrift_Ctrl_Val': 2.6, 'Udrift': 420.0}]
|
|
173
|
+
|
|
174
|
+
Since we didn't specify the full set of reaction-parameters, the self-check will fail:
|
|
175
|
+
>>> co.translate_op_modes(presets, check=True)
|
|
176
|
+
Traceback (most recent call last):
|
|
177
|
+
...
|
|
178
|
+
AssertionError: reaction-data missing in presets
|
|
179
|
+
|
|
180
|
+
'''
|
|
181
|
+
if preset_items is None:
|
|
182
|
+
raise ValueError('preset_items is None')
|
|
183
|
+
|
|
184
|
+
# Note: the `preset_items` is a dict[step_index] ~> (name, preset_items)
|
|
185
|
+
# and in the items one would expect these keys:
|
|
186
|
+
preset_keys = {
|
|
187
|
+
'PrimionIdx': ('PrimionIdx', '', 'INT'),
|
|
188
|
+
'TransmissionIdx': ('TransmissionIdx', '', 'INT'),
|
|
189
|
+
'DPS_Udrift': ('UDrift', 'Global_DTS500.TR_DTS500_Set[0].SetU_Udrift', 'FLOAT'),
|
|
190
|
+
'DPS_Pdrift_Ctrl_Val': ('Drift', 'Global_System.DriftPressureSet', 'FLOAT'),
|
|
191
|
+
'T-Drift': ('T-Drift[°C]', 'Global_Temperatures.TempsSet[0]', 'FLOAT'),
|
|
192
|
+
}
|
|
193
|
+
# make a deep copy of the `set_values`:
|
|
194
|
+
set_values = [dict(step.set_values) for step in self.steps]
|
|
195
|
+
carry = dict()
|
|
196
|
+
for entry in set_values:
|
|
197
|
+
# replace OP_Mode with the stuff found in preset_items
|
|
198
|
+
if 'OP_Mode' in entry:
|
|
199
|
+
index = entry['OP_Mode']
|
|
200
|
+
name, items = preset_items[index]
|
|
201
|
+
for parID, key in preset_keys.items():
|
|
202
|
+
if key in items:
|
|
203
|
+
entry[parID] = items[key]
|
|
204
|
+
del entry['OP_Mode']
|
|
205
|
+
|
|
206
|
+
# Note: each preset is only an update of set-values over what has already
|
|
207
|
+
# been set. thus, when following the sequence of OP_Modes, each one must
|
|
208
|
+
# carry with it the set-values of all its predecessors:
|
|
209
|
+
carry.update(entry)
|
|
210
|
+
entry.update(carry)
|
|
211
|
+
if check:
|
|
212
|
+
assert all(key in entry for key in preset_keys), "reaction-data missing in presets"
|
|
213
|
+
|
|
214
|
+
return set_values
|
|
215
|
+
|
|
125
216
|
def sequence(self):
|
|
126
217
|
'''A (possibly infinite) iterator over this Composition's future_cycles and steps.
|
|
127
218
|
|
|
@@ -130,27 +221,31 @@ class Composition(Iterable):
|
|
|
130
221
|
The first 'future_cycle' is 0 unless otherwise specified with class-parameter 'start_cycle'.
|
|
131
222
|
This generates AME_Run/Step-Number and AUTO_UseMean unless otherwise specified.
|
|
132
223
|
'''
|
|
224
|
+
_offset_ame = True # whether ame-numbers should mark the *next* cycle, see [#2897]
|
|
225
|
+
|
|
133
226
|
future_cycle = self.start_cycle
|
|
134
227
|
if self.start_action is not None:
|
|
135
|
-
yield future_cycle, dict([(self.ACTION_MARKER, int(self.start_action))])
|
|
228
|
+
yield future_cycle + int(_offset_ame), dict([(self.ACTION_MARKER, int(self.start_action))])
|
|
136
229
|
|
|
137
|
-
for run, step,
|
|
138
|
-
|
|
139
|
-
if step == 1:
|
|
140
|
-
automation[self.RUN_MARKER] = run
|
|
230
|
+
for run, step, step_info in self:
|
|
231
|
+
yield future_cycle, dict(step_info.set_values)
|
|
141
232
|
|
|
142
|
-
set_values = dict(current.set_values)
|
|
143
233
|
if self.generate_automation:
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
234
|
+
automation = {self.STEP_MARKER: step}
|
|
235
|
+
if step == 1:
|
|
236
|
+
automation[self.RUN_MARKER] = run
|
|
237
|
+
|
|
238
|
+
if step_info.start_delay == 0:
|
|
239
|
+
# all cycles get the AUTO_UseMean flag set to True:
|
|
240
|
+
automation[self.USE_MARKER] = 1
|
|
241
|
+
yield future_cycle + int(_offset_ame), automation
|
|
242
|
+
else:
|
|
243
|
+
# split into two updates for AUTO_UseMean flag:
|
|
244
|
+
automation[self.USE_MARKER] = 0
|
|
245
|
+
yield future_cycle + int(_offset_ame), automation
|
|
246
|
+
yield future_cycle + int(_offset_ame) + step_info.start_delay, {self.USE_MARKER: 1}
|
|
247
|
+
|
|
248
|
+
future_cycle = future_cycle + step_info.duration
|
|
154
249
|
|
|
155
250
|
@coroutine
|
|
156
251
|
def schedule_routine(self, schedule_fun):
|
pytrms/helpers.py
CHANGED
|
@@ -1,6 +1,120 @@
|
|
|
1
|
-
|
|
1
|
+
"""@file helpers.py
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
common helper functions.
|
|
4
|
+
"""
|
|
5
5
|
|
|
6
|
+
def convert_labview_to_posix(lv_time_utc, utc_offset_sec):
|
|
7
|
+
'''Create a `pandas.Timestamp` from LabView time.'''
|
|
8
|
+
from pandas import Timestamp
|
|
9
|
+
|
|
10
|
+
# change epoch from 01.01.1904 to 01.01.1970:
|
|
11
|
+
posix_time = lv_time_utc - 2082844800
|
|
12
|
+
# the tz must be specified in isoformat like '+02:30'..
|
|
13
|
+
tz_sec = int(utc_offset_sec)
|
|
14
|
+
tz_designator = '{0}{1:02d}:{2:02d}'.format(
|
|
15
|
+
'+' if tz_sec >= 0 else '-', tz_sec // 3600, tz_sec % 3600 // 60)
|
|
16
|
+
|
|
17
|
+
return Timestamp(posix_time, unit='s', tz=tz_designator)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_presets_file(presets_file):
|
|
21
|
+
'''Load a `presets_file` as XML-tree and interpret the "OP_Mode" of this `Composition`.
|
|
22
|
+
|
|
23
|
+
The tricky thing is, that any OP_Mode may or may not override previous settings!
|
|
24
|
+
Therefore, it depends on the order of modes in this Composition to be able to assign
|
|
25
|
+
each OP_Mode its actual dictionary of set_values.
|
|
26
|
+
|
|
27
|
+
Note, that the preset file uses its own naming convention that cannot neccessarily be
|
|
28
|
+
translated into standard parID-names. You may choose whatever you like to do with it.
|
|
29
|
+
'''
|
|
30
|
+
import xml.etree.ElementTree as ET
|
|
31
|
+
from collections import namedtuple, defaultdict
|
|
32
|
+
|
|
33
|
+
_key = namedtuple('preset_item', ['name', 'ads_path', 'dtype'])
|
|
34
|
+
_parse_value = {
|
|
35
|
+
"FLOAT": float,
|
|
36
|
+
"BOOL": bool,
|
|
37
|
+
"BYTE": int,
|
|
38
|
+
"ENUM": int,
|
|
39
|
+
}
|
|
40
|
+
tree = ET.parse(presets_file)
|
|
41
|
+
root = tree.getroot()
|
|
42
|
+
|
|
43
|
+
preset_names = {}
|
|
44
|
+
preset_items = defaultdict(dict)
|
|
45
|
+
for index, preset in enumerate(root.iterfind('preset')):
|
|
46
|
+
preset_names[index] = preset.find('name').text.strip()
|
|
47
|
+
|
|
48
|
+
if preset.find('WritePrimIon').text.upper() == "TRUE":
|
|
49
|
+
val = preset.find('IndexPrimIon').text
|
|
50
|
+
preset_items[index][_key('PrimionIdx', '', 'INT')] = int(val)
|
|
51
|
+
|
|
52
|
+
if preset.find('WriteTransmission').text.upper() == "TRUE":
|
|
53
|
+
val = preset.find('IndexTransmission').text
|
|
54
|
+
preset_items[index][_key('TransmissionIdx', '', 'INT')] = int(val)
|
|
55
|
+
|
|
56
|
+
for item in preset.iterfind('item'):
|
|
57
|
+
if item.find('Write').text.upper() == "TRUE":
|
|
58
|
+
# device_index = item.find('DeviceIndex').text
|
|
59
|
+
ads_path = item.find('AdsPath').text
|
|
60
|
+
data_type = item.find('DataType').text
|
|
61
|
+
# page_name = item.find('PageName').text
|
|
62
|
+
name = item.find('Name').text
|
|
63
|
+
value_text = item.find('Value').text
|
|
64
|
+
|
|
65
|
+
key = _key(name, ads_path, data_type)
|
|
66
|
+
val = _parse_value[data_type](value_text)
|
|
67
|
+
preset_items[index][key] = val
|
|
68
|
+
|
|
69
|
+
return {index: (preset_names[index], preset_items[index]) for index in preset_names.keys()}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def setup_measurement_dir(config_dir=None, data_root_dir='D:/Data', suffix='',
|
|
73
|
+
date_fmt = "%Y_%m_%d__%H_%M_%S"):
|
|
74
|
+
"""Create a new directory for saving the measurement and set it up.
|
|
75
|
+
|
|
76
|
+
Optional: copy all files from the given config-directory.
|
|
77
|
+
|
|
78
|
+
data_root_dir: the base folder for storing new measurement-directories
|
|
79
|
+
suffix: will be appended to directory and data-file
|
|
80
|
+
date_fmt: format for the source-folder and -file to be timestamped
|
|
81
|
+
"""
|
|
82
|
+
import os
|
|
83
|
+
import glob
|
|
84
|
+
import shutil
|
|
85
|
+
from collections import namedtuple
|
|
86
|
+
from datetime import datetime
|
|
87
|
+
from itertools import chain
|
|
88
|
+
|
|
89
|
+
recipe = namedtuple('recipe', ['dirname', 'h5_file', 'pt_file', 'alarms_file'])
|
|
90
|
+
_pt_formats = ['*.ionipt']
|
|
91
|
+
_al_formats = ['*.alm']
|
|
92
|
+
# make directory with current timestamp:
|
|
93
|
+
now = datetime.now()
|
|
94
|
+
new_h5_file = os.path.abspath(os.path.join(
|
|
95
|
+
data_root_dir,
|
|
96
|
+
now.strftime(date_fmt) + suffix,
|
|
97
|
+
now.strftime(date_fmt) + suffix + '.h5',
|
|
98
|
+
))
|
|
99
|
+
new_recipe_dir = os.path.dirname(new_h5_file)
|
|
100
|
+
os.makedirs(new_recipe_dir, exist_ok=False) # may throw!
|
|
101
|
+
if not config_dir:
|
|
102
|
+
# we're done here..
|
|
103
|
+
return recipe(new_recipe_dir, new_h5_file, '', '')
|
|
104
|
+
|
|
105
|
+
# find the *first* matching file or an empty string if no match...
|
|
106
|
+
new_pt_file = next(chain.from_iterable(glob.iglob(config_dir + "/" + g) for g in _pt_formats), '')
|
|
107
|
+
new_al_file = next(chain.from_iterable(glob.iglob(config_dir + "/" + g) for g in _al_formats), '')
|
|
108
|
+
# ...and copy all files from the master-recipe-dir:
|
|
109
|
+
files2copy = glob.glob(config_dir + "/*")
|
|
110
|
+
for file in files2copy:
|
|
111
|
+
new_file = shutil.copy(file, new_recipe_dir)
|
|
112
|
+
try: # remove write permission (a.k.a. make files read-only)
|
|
113
|
+
mode = os.stat(file).st_mode
|
|
114
|
+
os.chmod(new_file, mode & ~stat.S_IWRITE)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
# well, we can't set write permission
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
return recipe(new_recipe_dir, new_h5_file, new_pt_file, new_al_file)
|
|
6
120
|
|
pytrms/instrument.py
CHANGED
|
@@ -17,7 +17,6 @@ class Instrument(ABC):
|
|
|
17
17
|
Note, that for every client PTR instrument there is only one instance of this class.
|
|
18
18
|
This is to prevent different instances to be in other states than the instrument.
|
|
19
19
|
'''
|
|
20
|
-
|
|
21
20
|
__instance = None
|
|
22
21
|
|
|
23
22
|
def _new_state(self, newstate):
|
|
@@ -69,38 +68,41 @@ class Instrument(ABC):
|
|
|
69
68
|
"""Set a variable to a new value."""
|
|
70
69
|
return self.backend.set(varname, value, unit='-')
|
|
71
70
|
|
|
71
|
+
_current_sourcefile = ''
|
|
72
|
+
|
|
72
73
|
def start_measurement(self, filename=''):
|
|
73
74
|
# this method must be implemented by each state
|
|
74
75
|
raise RuntimeError("can't start %s" % self.__class__)
|
|
75
76
|
|
|
76
|
-
start_measurement.__doc__ = Measurement.start.__doc__
|
|
77
|
-
|
|
78
77
|
def stop_measurement(self):
|
|
79
78
|
# this method must be implemented by each state
|
|
80
79
|
raise RuntimeError("can't stop %s" % self.__class__)
|
|
81
80
|
|
|
82
|
-
stop_measurement.__doc__ = Measurement.stop.__doc__
|
|
83
|
-
|
|
84
81
|
|
|
85
82
|
class IdleInstrument(Instrument):
|
|
86
83
|
|
|
87
84
|
def start_measurement(self, filename=''):
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
os.makedirs(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
85
|
+
dirname = os.path.dirname(filename)
|
|
86
|
+
if dirname and self.is_local:
|
|
87
|
+
# Note: if we send a filepath to the server that does not exist there, the
|
|
88
|
+
# server will open a dialog and "hang" (which I'd very much like to avoid).
|
|
89
|
+
# the safest way is to not send a path at all and start a 'Quick' measurement.
|
|
90
|
+
# but if the server is the local machine, we do our best to verify the path:
|
|
91
|
+
os.makedirs(dirname, exist_ok=True)
|
|
92
|
+
|
|
93
|
+
if filename:
|
|
94
|
+
basename = os.path.basename(filename)
|
|
95
|
+
# this may very well be a directory to record a filename into:
|
|
96
|
+
if not basename:
|
|
97
|
+
basename = '%Y-%m-%d_%H-%M-%S.h5'
|
|
98
|
+
filename = os.path.join(dirname, basename)
|
|
99
|
+
# finally, pass everything through strftime...
|
|
100
|
+
filename = time.strftime(filename)
|
|
100
101
|
if os.path.exists(filename):
|
|
101
102
|
raise RuntimeError(f'filename exists and cannot be overwritten')
|
|
102
103
|
|
|
103
104
|
self.backend.start_measurement(filename)
|
|
105
|
+
self._current_sourcefile = filename
|
|
104
106
|
self._new_state(RunningInstrument)
|
|
105
107
|
|
|
106
108
|
return RunningMeasurement(self)
|
|
@@ -112,3 +114,6 @@ class RunningInstrument(Instrument):
|
|
|
112
114
|
self.backend.stop_measurement()
|
|
113
115
|
self._new_state(IdleInstrument)
|
|
114
116
|
|
|
117
|
+
# TODO :: this catches only one sourcefile.. it'll do for simple cases:
|
|
118
|
+
return FinishedMeasurement(_current_sourcefile)
|
|
119
|
+
|
pytrms/measurement.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import time
|
|
2
|
-
from glob import glob
|
|
3
2
|
from operator import attrgetter
|
|
4
3
|
from itertools import chain
|
|
5
4
|
from abc import abstractmethod, ABC
|
|
6
5
|
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
7
8
|
from .readers import IoniTOFReader
|
|
8
9
|
|
|
9
10
|
__all__ = ['Measurement', 'PreparingMeasurement', 'RunningMeasurement', 'FinishedMeasurement']
|
|
@@ -145,16 +146,14 @@ class FinishedMeasurement(Measurement):
|
|
|
145
146
|
def timebin_width_ps(self):
|
|
146
147
|
return next(iter(self.sourcefiles)).timebin_width_ps
|
|
147
148
|
|
|
148
|
-
def __init__(self, filenames, _reader=IoniTOFReader):
|
|
149
|
-
if isinstance(filenames, str):
|
|
150
|
-
filenames = glob(filenames)
|
|
149
|
+
def __init__(self, *filenames, _reader=IoniTOFReader):
|
|
151
150
|
if not len(filenames):
|
|
152
|
-
raise ValueError("
|
|
151
|
+
raise ValueError("no filename given")
|
|
153
152
|
|
|
154
153
|
self.sourcefiles = sorted((_reader(f) for f in filenames), key=attrgetter('time_of_file'))
|
|
155
154
|
self._check(self.sourcefiles)
|
|
156
155
|
|
|
157
|
-
def
|
|
156
|
+
def read_traces(self, kind='conc', index='abs_cycle', force_original=False):
|
|
158
157
|
"""Return the timeseries ("traces") of all masses, compounds and settings.
|
|
159
158
|
|
|
160
159
|
'kind' is the type of traces and must be one of 'raw', 'concentration' or
|
|
@@ -164,8 +163,11 @@ class FinishedMeasurement(Measurement):
|
|
|
164
163
|
'abs_time' or 'rel_time'.
|
|
165
164
|
|
|
166
165
|
"""
|
|
167
|
-
return
|
|
166
|
+
return pd.concat(sf.read_all(kind, index, force_original) for sf in self.sourcefiles)
|
|
167
|
+
|
|
168
|
+
def __iter__(self):
|
|
169
|
+
return iter(self.sourcefiles)
|
|
168
170
|
|
|
169
171
|
def __len__(self):
|
|
170
|
-
return
|
|
172
|
+
return len(self.sourcefiles)
|
|
171
173
|
|
pytrms/peaktable.py
CHANGED
|
@@ -35,7 +35,7 @@ other custom formats.
|
|
|
35
35
|
>>> pt
|
|
36
36
|
<PeakTable (1) [21.0u]>
|
|
37
37
|
>>> pt[0]
|
|
38
|
-
<Peak
|
|
38
|
+
<Peak @ 21.0219+0.0000 [H3O+]>
|
|
39
39
|
|
|
40
40
|
Peaks may be modified and the PeakTable exported in the same format:
|
|
41
41
|
>>> pt[0].formula = 'H3O'
|
|
@@ -56,7 +56,9 @@ Peaks may be modified and the PeakTable exported in the same format:
|
|
|
56
56
|
"parent": "?",
|
|
57
57
|
"isotopic_abundance": 0.678,
|
|
58
58
|
"k_rate": 2.1,
|
|
59
|
-
"multiplier": 488
|
|
59
|
+
"multiplier": 488.0,
|
|
60
|
+
"resolution": 1000.0,
|
|
61
|
+
"shift": 0.0
|
|
60
62
|
}
|
|
61
63
|
]
|
|
62
64
|
}
|
|
@@ -144,14 +146,14 @@ class Peak:
|
|
|
144
146
|
return self.center == round(float(other), Peak._exact_decimals)
|
|
145
147
|
|
|
146
148
|
def __hash__(self):
|
|
147
|
-
return hash(str(self.center)
|
|
149
|
+
return hash(str(self.center) + self.label)
|
|
148
150
|
|
|
149
151
|
def __float__(self):
|
|
150
152
|
return self.center
|
|
151
153
|
|
|
152
154
|
def __repr__(self):
|
|
153
|
-
return '<%s
|
|
154
|
-
self.
|
|
155
|
+
return '<%s @ %.4f+%.4f [%s]>' % (self.__class__.__name__,
|
|
156
|
+
self.center, self.shift, self.label)
|
|
155
157
|
|
|
156
158
|
|
|
157
159
|
class PeakTable:
|