pytrms 0.9.7__py3-none-any.whl → 0.9.8__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 +45 -1
- pytrms/clients/__init__.py +2 -26
- pytrms/clients/db_api.py +252 -65
- pytrms/clients/dummy.py +5 -4
- pytrms/clients/mqtt.py +2 -8
- pytrms/clients/ssevent.py +67 -38
- pytrms/data/ParaIDs.csv +3 -3
- pytrms/instrument.py +67 -26
- pytrms/peaktable.py +3 -4
- {pytrms-0.9.7.dist-info → pytrms-0.9.8.dist-info}/METADATA +1 -1
- {pytrms-0.9.7.dist-info → pytrms-0.9.8.dist-info}/RECORD +13 -13
- {pytrms-0.9.7.dist-info → pytrms-0.9.8.dist-info}/LICENSE +0 -0
- {pytrms-0.9.7.dist-info → pytrms-0.9.8.dist-info}/WHEEL +0 -0
pytrms/__init__.py
CHANGED
|
@@ -1,8 +1,52 @@
|
|
|
1
|
-
_version = '0.9.
|
|
1
|
+
_version = '0.9.8'
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from functools import wraps
|
|
5
|
+
|
|
6
|
+
_logging_getLogger = logging.getLogger
|
|
7
|
+
|
|
8
|
+
@wraps(_logging_getLogger)
|
|
9
|
+
def getLoggerWithAnnouncement(name=None):
|
|
10
|
+
# patch the (global) logger to print its own name
|
|
11
|
+
# (useful for turning individual loggers on/off)
|
|
12
|
+
# WARNING: this will patch every instance of the
|
|
13
|
+
# logging-module in every import after pytrms is
|
|
14
|
+
# imported! don't be overwhelmingly fancy with this!
|
|
15
|
+
rv = _logging_getLogger(name)
|
|
16
|
+
if name is not None:
|
|
17
|
+
rv.debug(f"'acquired logger for '{name}'")
|
|
18
|
+
|
|
19
|
+
return rv
|
|
20
|
+
|
|
21
|
+
logging.getLogger = getLoggerWithAnnouncement
|
|
22
|
+
logging.TRACE = 5 # even more verbose than logging.DEBUG
|
|
2
23
|
|
|
3
24
|
__all__ = ['load', 'connect']
|
|
4
25
|
|
|
5
26
|
|
|
27
|
+
def enable_extended_logging(log_level=logging.DEBUG):
|
|
28
|
+
'''make output of http-requests more talkative.
|
|
29
|
+
|
|
30
|
+
set 'log_level=logging.TRACE' for highest verbosity!
|
|
31
|
+
'''
|
|
32
|
+
if log_level <= logging.DEBUG:
|
|
33
|
+
# enable logging of http request urls on the library, that is
|
|
34
|
+
# underlying the 'requests'-package:
|
|
35
|
+
logging.warning(f"enabling logging-output on 'urllib3' ({log_level = })")
|
|
36
|
+
requests_log = logging.getLogger("urllib3")
|
|
37
|
+
requests_log.setLevel(log_level)
|
|
38
|
+
requests_log.propagate = True
|
|
39
|
+
|
|
40
|
+
if log_level <= logging.TRACE:
|
|
41
|
+
# Enabling debugging at http.client level (requests->urllib3->http.client)
|
|
42
|
+
# you will see the REQUEST, including HEADERS and DATA, and RESPONSE with
|
|
43
|
+
# HEADERS but without DATA. the only thing missing will be the response.body,
|
|
44
|
+
# which is not logged.
|
|
45
|
+
logging.warning(f"enabling logging-output on 'HTTPConnection' ({log_level = })")
|
|
46
|
+
from http.client import HTTPConnection
|
|
47
|
+
HTTPConnection.debuglevel = 1
|
|
48
|
+
|
|
49
|
+
|
|
6
50
|
def load(path):
|
|
7
51
|
'''Open a datafile for post-analysis or batch processing.
|
|
8
52
|
|
pytrms/clients/__init__.py
CHANGED
|
@@ -1,33 +1,9 @@
|
|
|
1
1
|
import os
|
|
2
2
|
|
|
3
|
+
from .. import enable_extended_logging
|
|
4
|
+
|
|
3
5
|
_root = os.path.dirname(__file__)
|
|
4
6
|
_par_id_file = os.path.abspath(os.path.join(_root, '..', 'data', 'ParaIDs.csv'))
|
|
5
7
|
assert os.path.exists(_par_id_file), "par-id file not found: please re-install PyTRMS package"
|
|
6
8
|
|
|
7
9
|
|
|
8
|
-
import logging as _logging
|
|
9
|
-
|
|
10
|
-
_logging.TRACE = 5 # even more verbose than logging.DEBUG
|
|
11
|
-
|
|
12
|
-
def enable_extended_logging(log_level=_logging.DEBUG):
|
|
13
|
-
'''make output of http-requests more talkative.
|
|
14
|
-
|
|
15
|
-
set 'log_level=_logging.TRACE' for highest verbosity!
|
|
16
|
-
'''
|
|
17
|
-
if log_level <= _logging.DEBUG:
|
|
18
|
-
# enable logging of http request urls on the library, that is
|
|
19
|
-
# underlying the 'requests'-package:
|
|
20
|
-
_logging.warning(f"enabling logging-output on 'urllib3' ({log_level = })")
|
|
21
|
-
requests_log = _logging.getLogger("urllib3")
|
|
22
|
-
requests_log.setLevel(log_level)
|
|
23
|
-
requests_log.propagate = True
|
|
24
|
-
|
|
25
|
-
if log_level <= _logging.TRACE:
|
|
26
|
-
# Enabling debugging at http.client level (requests->urllib3->http.client)
|
|
27
|
-
# you will see the REQUEST, including HEADERS and DATA, and RESPONSE with
|
|
28
|
-
# HEADERS but without DATA. the only thing missing will be the response.body,
|
|
29
|
-
# which is not logged.
|
|
30
|
-
_logging.warning(f"enabling logging-output on 'HTTPConnection' ({log_level = })")
|
|
31
|
-
from http.client import HTTPConnection
|
|
32
|
-
HTTPConnection.debuglevel = 1
|
|
33
|
-
|
pytrms/clients/db_api.py
CHANGED
|
@@ -1,21 +1,53 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import time
|
|
2
3
|
import json
|
|
4
|
+
import logging
|
|
5
|
+
from collections import namedtuple
|
|
6
|
+
import urllib3.util
|
|
3
7
|
|
|
4
8
|
import requests
|
|
9
|
+
import requests.adapters
|
|
10
|
+
import requests.exceptions
|
|
5
11
|
|
|
6
|
-
from . import _logging
|
|
7
12
|
from .ssevent import SSEventListener
|
|
8
13
|
from .._base import _IoniClientBase
|
|
9
14
|
|
|
10
|
-
log =
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_unsafe = namedtuple('http_response', ['status_code', 'href'])
|
|
18
|
+
|
|
19
|
+
__all__ = ['IoniConnect']
|
|
11
20
|
|
|
12
21
|
|
|
13
22
|
class IoniConnect(_IoniClientBase):
|
|
14
23
|
|
|
24
|
+
# Note: this retry-policy is specifically designed for the
|
|
25
|
+
# SQLite Error 5: 'database locked', which may take potentially
|
|
26
|
+
# minutes to resolve itself! Therefore, it is extra generous
|
|
27
|
+
# and backs off up to `3.0 * 2^4 = 48 sec` between retries for
|
|
28
|
+
# a total of ~1 1/2 minutes (plus database timeout). But, giving
|
|
29
|
+
# up on retrying here, would mean *losing all data* in the queue!
|
|
30
|
+
# ==>> We would rather crash on a `queue.full` exception! <<==
|
|
31
|
+
_retry_policy = urllib3.util.Retry(
|
|
32
|
+
# this configures policies on each cause for errors individually...
|
|
33
|
+
total=None, # max. retries (takes precedence). `None`: turned off
|
|
34
|
+
connect=0, read=0, redirect=0, # (all turned off, see docs for details)
|
|
35
|
+
other=0, # "other" errors include timeout (set to 27 seconds)
|
|
36
|
+
# configure the retries on specific status-codes...
|
|
37
|
+
status=5, # how many times to retry on bad status codes
|
|
38
|
+
raise_on_status=True, # `True`: do not return a 429 status code
|
|
39
|
+
status_forcelist=[429], # integer status-codes to retry on
|
|
40
|
+
allowed_methods=None, # `None`: retry on all (possibly not idempotent) verbs
|
|
41
|
+
# this configures backoff between retries...
|
|
42
|
+
backoff_factor=3.0, # back off *after* first try in seconds (x 2^n_retries)
|
|
43
|
+
respect_retry_after_header=False, # would override `backoff_factor`, turn off!
|
|
44
|
+
)
|
|
45
|
+
|
|
15
46
|
@property
|
|
16
47
|
def is_connected(self):
|
|
17
48
|
'''Returns `True` if connection to IoniTOF could be established.'''
|
|
18
49
|
try:
|
|
50
|
+
assert self.session is not None, "not connected"
|
|
19
51
|
self.get("/api/status")
|
|
20
52
|
return True
|
|
21
53
|
except:
|
|
@@ -24,88 +56,223 @@ class IoniConnect(_IoniClientBase):
|
|
|
24
56
|
@property
|
|
25
57
|
def is_running(self):
|
|
26
58
|
'''Returns `True` if IoniTOF is currently acquiring data.'''
|
|
27
|
-
|
|
28
|
-
|
|
59
|
+
try:
|
|
60
|
+
assert self.session is not None, "not connected"
|
|
61
|
+
self.get_location("/api/measurements/current")
|
|
62
|
+
return True
|
|
63
|
+
except (AssertionError, requests.exceptions.HTTPError):
|
|
64
|
+
return False
|
|
29
65
|
|
|
30
|
-
def connect(self, timeout_s):
|
|
31
|
-
|
|
32
|
-
|
|
66
|
+
def connect(self, timeout_s=10):
|
|
67
|
+
self.session = requests.sessions.Session()
|
|
68
|
+
self.session.mount('http://', self._http_adapter)
|
|
69
|
+
self.session.mount('https://', self._http_adapter)
|
|
70
|
+
started_at = time.monotonic()
|
|
71
|
+
while timeout_s is None or time.monotonic() < started_at + timeout_s:
|
|
72
|
+
try:
|
|
73
|
+
self.current_meas_loc = self.get_location("/api/measurements/current")
|
|
74
|
+
break
|
|
75
|
+
except requests.exceptions.HTTPError:
|
|
76
|
+
# OK, no measurement running..
|
|
77
|
+
self.current_meas_loc = ''
|
|
78
|
+
break
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
time.sleep(10e-1)
|
|
83
|
+
else:
|
|
84
|
+
self.session = self.current_meas_loc = None
|
|
85
|
+
raise TimeoutError(f"no connection to '{self.url}'");
|
|
33
86
|
|
|
34
87
|
def disconnect(self):
|
|
35
|
-
|
|
36
|
-
|
|
88
|
+
if self.session is not None:
|
|
89
|
+
del self.session
|
|
90
|
+
self.session = None
|
|
91
|
+
self.current_meas_loc = None
|
|
37
92
|
|
|
38
93
|
def start_measurement(self, path=None):
|
|
39
94
|
'''Start a new measurement and block until the change is confirmed.
|
|
40
95
|
|
|
41
96
|
If 'path' is not None, write to the given .h5 file.
|
|
42
97
|
'''
|
|
43
|
-
|
|
44
|
-
|
|
98
|
+
assert not self.is_running, "measurement already running @ " + str(self.current_meas_loc)
|
|
99
|
+
|
|
100
|
+
payload = {}
|
|
101
|
+
if path is not None:
|
|
102
|
+
assert os.path.isdir(path), "must point to a (recipe-)directory: " + str(path)
|
|
103
|
+
payload |= { "recipeDirectory": str(path) }
|
|
104
|
+
|
|
105
|
+
self.current_meas_loc = self.post("/api/measurements", payload)
|
|
106
|
+
self.put(self.current_meas_loc, { "isRunning": True })
|
|
107
|
+
|
|
108
|
+
return self.current_meas_loc
|
|
45
109
|
|
|
46
110
|
def stop_measurement(self, future_cycle=None):
|
|
47
111
|
'''Stop the current measurement and block until the change is confirmed.
|
|
48
112
|
|
|
49
113
|
If 'future_cycle' is not None and in the future, schedule the stop command.
|
|
50
114
|
'''
|
|
51
|
-
|
|
52
|
-
|
|
115
|
+
loc = self.current_meas_loc or self.get_location("/api/measurements/current")
|
|
116
|
+
self.patch(loc, { "isRunning": False })
|
|
117
|
+
self.current_meas_loc = ''
|
|
53
118
|
|
|
54
|
-
def __init__(self, host='127.0.0.1', port=5066
|
|
119
|
+
def __init__(self, host='127.0.0.1', port=5066):
|
|
55
120
|
super().__init__(host, port)
|
|
56
121
|
self.url = f"http://{self.host}:{self.port}"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
self.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
122
|
+
self._http_adapter = requests.adapters.HTTPAdapter(max_retries=self._retry_policy)
|
|
123
|
+
self.session = None
|
|
124
|
+
self.current_meas_loc = None
|
|
125
|
+
try:
|
|
126
|
+
self.connect(timeout_s=3.3)
|
|
127
|
+
except TimeoutError:
|
|
128
|
+
log.warning("no connection! make sure the DB-API is running and try again")
|
|
63
129
|
|
|
64
130
|
def get(self, endpoint, **kwargs):
|
|
65
|
-
|
|
131
|
+
"""Make a GET request to `endpoint` and parse JSON if applicable."""
|
|
132
|
+
try:
|
|
133
|
+
r = self._fetch_object(endpoint, 'get', **kwargs)
|
|
134
|
+
if 'json' in r.headers.get('content-type', ''):
|
|
135
|
+
return r.json()
|
|
136
|
+
if 'text' in r.headers.get('content-type', ''):
|
|
137
|
+
return r.text
|
|
138
|
+
else:
|
|
139
|
+
log.warning(f"unexpected 'content-type: {r.headers['content-type']}'")
|
|
140
|
+
log.info(f"did you mean to use `{type(self).__name__}.download(..)` instead?")
|
|
141
|
+
return r.content
|
|
142
|
+
|
|
143
|
+
except requests.exceptions.HTTPError as e:
|
|
144
|
+
if e.response.status_code == 410: # Gone
|
|
145
|
+
log.debug(f"nothing there at '{endpoint}' 0_o ?!")
|
|
146
|
+
return None
|
|
147
|
+
raise
|
|
148
|
+
|
|
149
|
+
def get_location(self, endpoint, **kwargs):
|
|
150
|
+
"""Returns the actual location that `endpoint` points to (may be a redirect)."""
|
|
151
|
+
r = self._fetch_object(endpoint, 'get', **(kwargs | { "allow_redirects": False }))
|
|
152
|
+
return r.headers.get('Location', r.request.path_url)
|
|
66
153
|
|
|
67
154
|
def post(self, endpoint, data, **kwargs):
|
|
68
|
-
|
|
155
|
+
"""Append to the collection at `endpoint` the object defined by `data`."""
|
|
156
|
+
r = self._create_object(endpoint, data, 'post', **kwargs)
|
|
157
|
+
return _unsafe(r.status_code, r.headers.get('Location', '')) # no default location known!
|
|
69
158
|
|
|
70
159
|
def put(self, endpoint, data, **kwargs):
|
|
71
|
-
|
|
160
|
+
"""Replace the entire object at `endpoint` with `data`."""
|
|
161
|
+
r = self._create_object(endpoint, data, 'put', **kwargs)
|
|
162
|
+
return _unsafe(r.status_code, r.headers.get('Location', r.request.path_url))
|
|
163
|
+
|
|
164
|
+
def patch(self, endpoint, data, **kwargs):
|
|
165
|
+
"""Change parts of the object at `endpoint` with fields in `data`."""
|
|
166
|
+
r = self._create_object(endpoint, data, 'patch', **kwargs)
|
|
167
|
+
return _unsafe(r.status_code, r.headers.get('Location', r.request.path_url))
|
|
168
|
+
|
|
169
|
+
def delete(self, endpoint, **kwargs):
|
|
170
|
+
"""Attempt to delete the object at `endpoint`."""
|
|
171
|
+
r = self._fetch_object(endpoint, data, 'delete', **kwargs)
|
|
172
|
+
return _unsafe(r.status_code, r.headers.get('Location', r.request.path_url))
|
|
173
|
+
|
|
174
|
+
def link(self, parent_ep, child_ep, **kwargs):
|
|
175
|
+
"""Make the object at `parent_e[nd]p[oint]` refer to `child_e[nd]p[oint]`"""
|
|
176
|
+
r = self._make_link(parent_ep, child_ep, sever=False, **kwargs)
|
|
177
|
+
return _unsafe(r.status_code, r.headers.get('Location', r.request.path_url))
|
|
178
|
+
|
|
179
|
+
def unlink(self, parent_ep, child_ep, **kwargs):
|
|
180
|
+
"""Destroy the reference from `parent_e[nd]p[oint]` to `child_e[nd]p[oint]`"""
|
|
181
|
+
r = self._make_link(parent_ep, child_ep, sever=True, **kwargs)
|
|
182
|
+
return _unsafe(r.status_code, r.headers.get('Location', r.request.path_url))
|
|
72
183
|
|
|
73
184
|
def upload(self, endpoint, filename):
|
|
185
|
+
"""Upload the file at `filename` to `endpoint`."""
|
|
74
186
|
if not endpoint.startswith('/'):
|
|
75
187
|
endpoint = '/' + endpoint
|
|
76
|
-
with open(filename) as f:
|
|
188
|
+
with open(filename, 'rb') as f:
|
|
77
189
|
# Note (important!): this is a "form-data" entry, where the server
|
|
78
190
|
# expects the "name" to be 'file' and rejects it otherwise:
|
|
79
191
|
name = 'file'
|
|
80
|
-
r = self.
|
|
192
|
+
r = self._create_object(endpoint, None, 'post',
|
|
193
|
+
# Note: the requests library will set the content-type automatically
|
|
194
|
+
# and also add a randomly generated "boundary" to separate files:
|
|
195
|
+
#headers={'content-type': 'multipart/form-data'}, No!
|
|
196
|
+
files=[(name, (filename, f, ''))])
|
|
81
197
|
r.raise_for_status()
|
|
82
198
|
|
|
83
|
-
return r
|
|
199
|
+
return _unsafe(r.status_code, r.headers.get('Location', r.request.path_url))
|
|
84
200
|
|
|
85
|
-
def
|
|
201
|
+
def download(self, endpoint, out_file='.'):
|
|
202
|
+
"""Download from `endpoint` into `out_file` (may be a directory).
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
status_code, actual_filename
|
|
206
|
+
"""
|
|
207
|
+
if not endpoint.startswith('/'):
|
|
208
|
+
endpoint = '/' + endpoint
|
|
209
|
+
|
|
210
|
+
out_file = os.path.abspath(out_file)
|
|
211
|
+
|
|
212
|
+
content_type = 'application/octet-stream'
|
|
213
|
+
r = self._fetch_object(endpoint, 'get', stream=True, headers={'accept': content_type})
|
|
214
|
+
assert r.headers['content-type'] == content_type, "unexcepted content-type"
|
|
215
|
+
|
|
216
|
+
content_dispo = r.headers['content-disposition'].split('; ')
|
|
217
|
+
#['attachment',
|
|
218
|
+
# 'filename=2025_10_06__13_23_32.h5',
|
|
219
|
+
# "filename*=UTF-8''2025_10_06__13_23_32.h5"]
|
|
220
|
+
filename = next(
|
|
221
|
+
(dispo.split('=')[1] for dispo in content_dispo if dispo.startswith("filename="))
|
|
222
|
+
, None)
|
|
223
|
+
if os.path.isdir(out_file):
|
|
224
|
+
assert filename, "no out_file given and server didn't supply filename"
|
|
225
|
+
out_file = os.path.join(out_file, filename)
|
|
226
|
+
|
|
227
|
+
with open(out_file, mode='xb') as f:
|
|
228
|
+
# chunk_size must be of type int or None. A value of None will
|
|
229
|
+
# function differently depending on the value of `stream`.
|
|
230
|
+
# stream=True will read data as it arrives in whatever size the
|
|
231
|
+
# chunks are received. If stream=False, data is returned as
|
|
232
|
+
# a single chunk.
|
|
233
|
+
for chunk in r.iter_content(chunk_size=None):
|
|
234
|
+
f.write(chunk)
|
|
235
|
+
r.close()
|
|
236
|
+
|
|
237
|
+
return _unsafe(r.status_code, out_file)
|
|
238
|
+
|
|
239
|
+
def _fetch_object(self, endpoint, method='get', **kwargs):
|
|
86
240
|
if not endpoint.startswith('/'):
|
|
87
241
|
endpoint = '/' + endpoint
|
|
88
242
|
if 'headers' not in kwargs:
|
|
89
|
-
kwargs['headers'] = {'
|
|
90
|
-
elif '
|
|
91
|
-
kwargs['headers'].update({'
|
|
243
|
+
kwargs['headers'] = {'accept': 'application/json'}
|
|
244
|
+
elif 'accept' not in (k.lower() for k in kwargs['headers']):
|
|
245
|
+
kwargs['headers'].update({'accept': 'application/json'})
|
|
92
246
|
if 'timeout' not in kwargs:
|
|
93
247
|
# https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
|
|
94
248
|
kwargs['timeout'] = (6.06, 27)
|
|
95
|
-
r = self.session.request(
|
|
249
|
+
r = self.session.request(method, self.url + endpoint, **kwargs)
|
|
96
250
|
r.raise_for_status()
|
|
97
|
-
|
|
251
|
+
|
|
252
|
+
return r
|
|
253
|
+
|
|
254
|
+
def _make_link(self, parent_href, child_href, *, sever=False, **kwargs):
|
|
255
|
+
verb = "LINK" if not sever else "UNLINK"
|
|
256
|
+
if 'timeout' not in kwargs:
|
|
257
|
+
# https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
|
|
258
|
+
kwargs['timeout'] = (6.06, 27)
|
|
259
|
+
r = self.session.request(verb, self.url + parent_href,
|
|
260
|
+
headers={"location": child_href}, **kwargs)
|
|
261
|
+
r.raise_for_status()
|
|
262
|
+
|
|
98
263
|
return r
|
|
99
264
|
|
|
100
265
|
def _create_object(self, endpoint, data, method='post', **kwargs):
|
|
101
266
|
if not endpoint.startswith('/'):
|
|
102
267
|
endpoint = '/' + endpoint
|
|
103
|
-
if not
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
268
|
+
if data is not None:
|
|
269
|
+
if not isinstance(data, str):
|
|
270
|
+
# Note: default is `ensure_ascii=True`, but this escapes Umlaute!
|
|
271
|
+
data = json.dumps(data, ensure_ascii=False)
|
|
272
|
+
if 'headers' not in kwargs:
|
|
273
|
+
kwargs['headers'] = {'content-type': 'application/json'}
|
|
274
|
+
elif 'content-type' not in (k.lower() for k in kwargs['headers']):
|
|
275
|
+
kwargs['headers'].update({'content-type': 'application/json'})
|
|
109
276
|
if 'timeout' not in kwargs:
|
|
110
277
|
# https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
|
|
111
278
|
kwargs['timeout'] = (6.06, 27)
|
|
@@ -122,8 +289,9 @@ class IoniConnect(_IoniClientBase):
|
|
|
122
289
|
from operator import attrgetter
|
|
123
290
|
|
|
124
291
|
# Note: the DB-API distinguishes between peaks with
|
|
125
|
-
# different center *and* name,
|
|
126
|
-
|
|
292
|
+
# different center *and* name, while the PyTRMS 'Peak'
|
|
293
|
+
# only distinguishes by center, so this is our key:
|
|
294
|
+
make_key = lambda p_info: (p_info['center'], p_info['name'])
|
|
127
295
|
|
|
128
296
|
if isinstance(peaktable, str):
|
|
129
297
|
log.info(f"loading peaktable '{peaktable}'...")
|
|
@@ -147,55 +315,74 @@ class IoniConnect(_IoniClientBase):
|
|
|
147
315
|
updates[make_key(payload)] = {'payload': payload}
|
|
148
316
|
|
|
149
317
|
log.info(f"fetching current peaktable from the server...")
|
|
318
|
+
pt_server = self.get('/api/peaks')['_embedded']['peaks']
|
|
150
319
|
# create a comparable collection of peaks already on the database by
|
|
151
320
|
# reducing the keys in the response to what we actually want to update:
|
|
152
321
|
db_peaks = {make_key(p): {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
322
|
+
'payload': {k: p[k] for k in conv.keys()},
|
|
323
|
+
'self': p['_links']['self'],
|
|
324
|
+
'parent': p['_links'].get('parent'),
|
|
156
325
|
} for p in self.get('/api/peaks')['_embedded']['peaks']}
|
|
157
326
|
|
|
158
327
|
to_update = updates.keys() & db_peaks.keys()
|
|
159
328
|
to_upload = updates.keys() - db_peaks.keys()
|
|
160
|
-
updated = 0
|
|
329
|
+
updated = up_to_date = 0
|
|
161
330
|
for key in sorted(to_update):
|
|
162
331
|
# check if an existing peak needs an update
|
|
163
332
|
if db_peaks[key]['payload'] == updates[key]['payload']:
|
|
164
333
|
# nothing to do..
|
|
165
334
|
log.debug(f"up-to-date: {key}")
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
335
|
+
up_to_date += 1
|
|
336
|
+
else:
|
|
337
|
+
self.put(db_peaks[key]['self']['href'], updates[key]['payload'])
|
|
338
|
+
log.info(f"updated: {key}")
|
|
339
|
+
updated += 1
|
|
171
340
|
|
|
172
341
|
if len(to_upload):
|
|
173
342
|
# Note: POSTing the embedded-collection is *miles faster*
|
|
174
343
|
# than doing separate requests for each peak!
|
|
175
|
-
payload = {
|
|
344
|
+
payload = {
|
|
345
|
+
'_embedded': {
|
|
346
|
+
'peaks': [updates[key]['payload']
|
|
347
|
+
for key in sorted(to_upload)]
|
|
348
|
+
}
|
|
349
|
+
}
|
|
176
350
|
self.post('/api/peaks', payload)
|
|
177
351
|
for key in sorted(to_upload):
|
|
178
352
|
log.info(f"added new: {key}")
|
|
353
|
+
# Note: we need the updated peaktable to learn about
|
|
354
|
+
# the href (id) assigned to newly added peaks:
|
|
355
|
+
pt_server = self.get('/api/peaks')['_embedded']['peaks']
|
|
356
|
+
|
|
357
|
+
log.info("repairing fitpeak~>nominal links...")
|
|
358
|
+
peak2href = {
|
|
359
|
+
Peak(p["center"], label=p["name"]): p["_links"]["self"]["href"]
|
|
360
|
+
for p in pt_server
|
|
361
|
+
}
|
|
362
|
+
to_link = set((peak2href[fitted], peak2href[fitted.parent])
|
|
363
|
+
for fitted in peaktable.fitted)
|
|
364
|
+
|
|
365
|
+
is_link = set((child["_links"]["self"]["href"], child["_links"]["parent"]["href"])
|
|
366
|
+
for child in pt_server if "parent" in child["_links"])
|
|
367
|
+
|
|
368
|
+
for child_href, parent_href in is_link & to_link:
|
|
369
|
+
log.debug(f"keep link {parent_href} <~> {child_href}")
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
for child_href, parent_href in to_link - is_link:
|
|
373
|
+
log.debug(f"make link {parent_href} ~>> {child_href}")
|
|
374
|
+
self.link(parent_href, child_href)
|
|
179
375
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
peak2href = {Peak(center=p["center"], label=p["name"]): p["_links"]["self"]["href"]
|
|
184
|
-
for p in self.get('/api/peaks')['_embedded']['peaks']}
|
|
185
|
-
|
|
186
|
-
for fitted in peaktable.fitted:
|
|
187
|
-
fitted_href = peak2href[fitted]
|
|
188
|
-
parent_href = peak2href[fitted.parent]
|
|
189
|
-
r = self.session.request('link', self.url + parent_href, headers={"location": fitted_href})
|
|
190
|
-
if not r.ok:
|
|
191
|
-
log.error(f"LINK {parent_href} to Location: {fitted_href} failed\n\n[{r.status_code}]: {r.content}")
|
|
192
|
-
r.raise_for_status()
|
|
193
|
-
log.debug(f"linked parent {parent_href} ~> {fitted_href}")
|
|
376
|
+
for child_href, parent_href in is_link - to_link:
|
|
377
|
+
log.debug(f'break link {parent_href} ~x~ {child_href}')
|
|
378
|
+
self.unlink(parent_href, child_href)
|
|
194
379
|
|
|
195
380
|
return {
|
|
196
381
|
'added': len(to_upload),
|
|
197
382
|
'updated': updated,
|
|
198
|
-
'up-to-date':
|
|
383
|
+
'up-to-date': up_to_date,
|
|
384
|
+
'linked': len(to_link - is_link),
|
|
385
|
+
'unlinked': len(is_link - to_link),
|
|
199
386
|
}
|
|
200
387
|
|
|
201
388
|
def iter_events(self, event_re=r".*"):
|
pytrms/clients/dummy.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
|
|
1
|
+
import logging
|
|
2
|
+
|
|
2
3
|
from .._base import _IoniClientBase
|
|
3
4
|
|
|
4
|
-
log =
|
|
5
|
+
log = logging.getLogger(__name__)
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
class IoniDummy(_IoniClientBase):
|
|
@@ -21,7 +22,7 @@ class IoniDummy(_IoniClientBase):
|
|
|
21
22
|
|
|
22
23
|
__is_running = False
|
|
23
24
|
|
|
24
|
-
def connect(self, timeout_s):
|
|
25
|
+
def connect(self, timeout_s=0):
|
|
25
26
|
log.info(f'pretending to connect to server')
|
|
26
27
|
self.__is_connected = True
|
|
27
28
|
|
|
@@ -37,7 +38,7 @@ class IoniDummy(_IoniClientBase):
|
|
|
37
38
|
log.info(f'pretending to stop measurement ({future_cycle = })')
|
|
38
39
|
self.__is_running = False
|
|
39
40
|
|
|
40
|
-
def __init__(self, host='localhost', port=
|
|
41
|
+
def __init__(self, host='localhost', port=1234):
|
|
41
42
|
super().__init__(host, port)
|
|
42
43
|
self.connect()
|
|
43
44
|
|
pytrms/clients/mqtt.py
CHANGED
|
@@ -2,18 +2,18 @@ import os
|
|
|
2
2
|
import time
|
|
3
3
|
import json
|
|
4
4
|
import queue
|
|
5
|
+
import logging
|
|
5
6
|
from collections import deque, namedtuple
|
|
6
7
|
from datetime import datetime
|
|
7
8
|
from functools import wraps
|
|
8
9
|
from itertools import cycle, chain, zip_longest
|
|
9
10
|
from threading import Condition, RLock
|
|
10
11
|
|
|
11
|
-
from . import _logging
|
|
12
12
|
from . import _par_id_file
|
|
13
13
|
from .._base import itype, _MqttClientBase, _IoniClientBase
|
|
14
14
|
|
|
15
15
|
|
|
16
|
-
log =
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
17
|
|
|
18
18
|
__all__ = ['MqttClient']
|
|
19
19
|
|
|
@@ -22,12 +22,6 @@ with open(_par_id_file) as f:
|
|
|
22
22
|
from pandas import read_csv, isna
|
|
23
23
|
|
|
24
24
|
_par_id_info = read_csv(f, sep='\t').drop(0).set_index('Name').fillna('')
|
|
25
|
-
if isna(_par_id_info.at['MPV_1', 'Access']):
|
|
26
|
-
log.warning(f'filling in read-properties still missing in {os.path.basename(_par_id_file)}')
|
|
27
|
-
_par_id_info.at['MPV_1', 'Access'] = 'RW'
|
|
28
|
-
_par_id_info.at['MPV_2', 'Access'] = 'RW'
|
|
29
|
-
_par_id_info.at['MPV_3', 'Access'] = 'RW'
|
|
30
|
-
|
|
31
25
|
|
|
32
26
|
|
|
33
27
|
## >>>>>>>> adaptor functions <<<<<<<< ##
|
pytrms/clients/ssevent.py
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import re
|
|
2
|
+
import time
|
|
3
|
+
import logging
|
|
2
4
|
from collections import namedtuple
|
|
3
5
|
from collections.abc import Iterable
|
|
4
6
|
|
|
5
7
|
import requests
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
log = _logging.getLogger(__name__)
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
10
|
|
|
11
11
|
_event_rv = namedtuple('ssevent', ['event', 'data'])
|
|
12
12
|
|
|
13
|
+
|
|
13
14
|
class SSEventListener(Iterable):
|
|
14
15
|
|
|
15
16
|
@staticmethod
|
|
@@ -29,7 +30,6 @@ class SSEventListener(Iterable):
|
|
|
29
30
|
self._get = session.get
|
|
30
31
|
else:
|
|
31
32
|
self._get = requests.get
|
|
32
|
-
self._connect_response = None
|
|
33
33
|
self.subscriptions = set()
|
|
34
34
|
if event_re is not None:
|
|
35
35
|
self.subscribe(event_re)
|
|
@@ -37,46 +37,75 @@ class SSEventListener(Iterable):
|
|
|
37
37
|
def subscribe(self, event_re):
|
|
38
38
|
"""Listen for events matching the given string or regular expression."""
|
|
39
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
40
|
|
|
48
41
|
def unsubscribe(self, event_re):
|
|
49
42
|
"""Stop listening for certain events."""
|
|
50
43
|
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
|
|
55
44
|
|
|
56
|
-
def
|
|
57
|
-
|
|
45
|
+
def follow_events(self, timeout_s=None, prime=False):
|
|
46
|
+
"""Returns a generator that produces events as soon as they are emitted.
|
|
47
|
+
|
|
48
|
+
When `timeout_s` is given, a hard timeout is set, after which the stream
|
|
49
|
+
is closed and the generator raises `StopIteration`. This makes it possible
|
|
50
|
+
to e.g. collect events into a list or test for an event to occur.
|
|
51
|
+
|
|
52
|
+
With `prime=True` the first (pseudo-)event will be generated immediately
|
|
53
|
+
after the connection has been made. The event type is 'new connection'.
|
|
54
|
+
|
|
55
|
+
_Note_: The timeout cannot be accurate, because the `requests` library only
|
|
56
|
+
allows to check the timeout when either an event or a keep-alive is received!
|
|
57
|
+
This may take up to 13 seconds (currently set on the API). Also, the last
|
|
58
|
+
event may be discarded.
|
|
59
|
+
|
|
60
|
+
`iter(<instance>)` calls this method with `timeout_s=None`.
|
|
61
|
+
"""
|
|
62
|
+
if not len(self.subscriptions):
|
|
58
63
|
raise Exception("call .subscribe() first to listen for events")
|
|
59
64
|
|
|
65
|
+
log.debug(f"opening connection to {self.uri}")
|
|
66
|
+
_response = self._get(self.uri, headers={'accept': 'text/event-stream'}, stream=True)
|
|
67
|
+
if not _response.ok:
|
|
68
|
+
log.error(f"no connection to {self.uri} (got [{_response.status_code}])")
|
|
69
|
+
_response.raise_for_status()
|
|
70
|
+
|
|
71
|
+
if prime:
|
|
72
|
+
yield _event_rv('new connection', '/api/status')
|
|
73
|
+
|
|
74
|
+
started_at = time.monotonic()
|
|
60
75
|
event = msg = ''
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
try:
|
|
77
|
+
for line in self._line_stream(_response): # blocks...
|
|
78
|
+
elapsed_s = time.monotonic() - started_at
|
|
79
|
+
if timeout_s is not None and elapsed_s > timeout_s:
|
|
80
|
+
log.debug(f"no more events after {round(elapsed_s)} seconds")
|
|
81
|
+
return # (raises StopIteration)
|
|
82
|
+
|
|
83
|
+
if not line:
|
|
84
|
+
# an empty line concludes an event
|
|
85
|
+
if event and any(re.match(sub, event) for sub in self.subscriptions):
|
|
86
|
+
yield _event_rv(event, msg)
|
|
87
|
+
|
|
88
|
+
# Note: any further empty lines are ignored (may be used as keep-alive),
|
|
89
|
+
# but in either case clear event and msg to rearm for the next event:
|
|
90
|
+
event = msg = ''
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
key, val = line.split(':', maxsplit=1)
|
|
94
|
+
if not key:
|
|
95
|
+
# this is a comment, starting with a colon ':' ...
|
|
96
|
+
log.log(logging.TRACE, "sse:" + val)
|
|
97
|
+
elif key == 'event':
|
|
98
|
+
event = val.lstrip()
|
|
99
|
+
elif key == 'data':
|
|
100
|
+
msg += val.lstrip()
|
|
101
|
+
else:
|
|
102
|
+
log.warning(f"unknown SSE-key <{key}> in stream")
|
|
103
|
+
finally:
|
|
104
|
+
_response.close()
|
|
105
|
+
log.debug(f"closed connection to {self.uri}")
|
|
106
|
+
|
|
107
|
+
def __iter__(self):
|
|
108
|
+
g = self.follow_events(timeout_s=None, prime=True)
|
|
109
|
+
assert next(g).event == 'new connection', "invalid program: pseude-event expected"
|
|
110
|
+
yield from g
|
|
82
111
|
|
pytrms/data/ParaIDs.csv
CHANGED
|
@@ -69,9 +69,9 @@ ID Name DataType Access ServerName LVWriteQueueName SharedVariable PrettyName Un
|
|
|
69
69
|
67 MPV_Dir_1 I32 PTR PTR_Write MPV Dir
|
|
70
70
|
68 MPV_Dir_2 I32 PTR PTR_Write MPV Dir2
|
|
71
71
|
69 MPV_Dir_3 I32 PTR PTR_Write MPV Dir3
|
|
72
|
-
70 MPV_1
|
|
73
|
-
71 MPV_2
|
|
74
|
-
72 MPV_3
|
|
72
|
+
70 MPV_1 RW PTR PTR_Write MPValve1
|
|
73
|
+
71 MPV_2 RW PTR PTR_Write MPValve2
|
|
74
|
+
72 MPV_3 RW PTR PTR_Write MPValve3
|
|
75
75
|
73 DPS_Uf DBL RW PTR PTR_Write Uf V
|
|
76
76
|
74 DPS_U1 DBL RW PTR PTR_Write UF1 V
|
|
77
77
|
75 DPS_U2 DBL RW PTR PTR_Write UF2 V
|
pytrms/instrument.py
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@file instrument.py
|
|
3
|
+
|
|
4
|
+
"""
|
|
1
5
|
import os.path
|
|
2
6
|
import time
|
|
3
7
|
from abc import abstractmethod, ABC
|
|
4
8
|
|
|
5
|
-
from .
|
|
6
|
-
RunningMeasurement,
|
|
7
|
-
FinishedMeasurement,
|
|
8
|
-
)
|
|
9
|
+
from ._base import _IoniClientBase
|
|
9
10
|
|
|
10
11
|
__all__ = ['Instrument']
|
|
11
12
|
|
|
@@ -14,13 +15,35 @@ class Instrument(ABC):
|
|
|
14
15
|
'''
|
|
15
16
|
Class for controlling the PTR instrument remotely.
|
|
16
17
|
|
|
17
|
-
This class reflects the states of the actual instrument, which can be currently
|
|
18
|
-
or running
|
|
19
|
-
instrument can be stopped.
|
|
20
|
-
|
|
18
|
+
This class reflects the states of the actual instrument, which can be currently
|
|
19
|
+
either idle or running. A idle instrument can start a measurement. A running
|
|
20
|
+
instrument can be stopped.
|
|
21
|
+
|
|
22
|
+
The `Instrument` class wraps a `backend`. For testing purposes, we use a mock:
|
|
23
|
+
>>> from pytrms.clients import dummy
|
|
24
|
+
>>> backend = dummy.IoniDummy()
|
|
25
|
+
|
|
26
|
+
Note, that for every client PTR instrument there is only one instance of this class
|
|
27
|
+
(this is to prevent different instances to be in other states than the instrument).
|
|
28
|
+
|
|
29
|
+
>>> ptr = Instrument(backend)
|
|
30
|
+
>>> ptr
|
|
31
|
+
<_IdleInstrument [<IoniDummy @ 127.0.0.1[:1234]>]>
|
|
32
|
+
|
|
33
|
+
>>> id(ptr) == id(Instrument(dummy.IoniDummy())) # singleton ID is always the same
|
|
34
|
+
True
|
|
35
|
+
|
|
36
|
+
Trying to start an instrument twice will raise a RuntimeError!
|
|
37
|
+
|
|
38
|
+
>>> ptr.start_measurement(filename='foo %y %M')
|
|
39
|
+
>>> ptr.is_running
|
|
40
|
+
True
|
|
41
|
+
|
|
42
|
+
>>> ptr.start_measurement()
|
|
43
|
+
Traceback (most recent call last):
|
|
44
|
+
...
|
|
45
|
+
RuntimeError: can't start <_RunningInstrument>
|
|
21
46
|
|
|
22
|
-
Note, that for every client PTR instrument there is only one instance of this class.
|
|
23
|
-
This is to prevent different instances to be in other states than the instrument.
|
|
24
47
|
'''
|
|
25
48
|
__instance = None
|
|
26
49
|
|
|
@@ -28,7 +51,7 @@ class Instrument(ABC):
|
|
|
28
51
|
# Note: we get ourselves a nifty little state-machine :)
|
|
29
52
|
self.__class__ = newstate
|
|
30
53
|
|
|
31
|
-
def __new__(cls,
|
|
54
|
+
def __new__(cls, *args, **kwargs):
|
|
32
55
|
# Note (reminder): If __new__() does not return an instance of cls,
|
|
33
56
|
# then the new instance’s __init__() method will *not* be invoked!
|
|
34
57
|
#
|
|
@@ -38,6 +61,9 @@ class Instrument(ABC):
|
|
|
38
61
|
if cls._Instrument__instance is not None:
|
|
39
62
|
return cls._Instrument__instance
|
|
40
63
|
|
|
64
|
+
backend = args[0] # fetch it from first argument (passed to __init__)
|
|
65
|
+
assert isinstance(backend, (_IoniClientBase)), f"backend must implement {type(_IoniClientBase)}"
|
|
66
|
+
|
|
41
67
|
if backend.is_running:
|
|
42
68
|
inst = object.__new__(_RunningInstrument)
|
|
43
69
|
else:
|
|
@@ -47,9 +73,9 @@ class Instrument(ABC):
|
|
|
47
73
|
|
|
48
74
|
return inst
|
|
49
75
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
self
|
|
76
|
+
@property
|
|
77
|
+
def is_running(self):
|
|
78
|
+
return type(self) is _RunningInstrument
|
|
53
79
|
|
|
54
80
|
@property
|
|
55
81
|
def is_local(self):
|
|
@@ -57,6 +83,13 @@ class Instrument(ABC):
|
|
|
57
83
|
host = str(self.backend.host)
|
|
58
84
|
return 'localhost' in host or '127.0.0.1' in host
|
|
59
85
|
|
|
86
|
+
def __init__(self, backend):
|
|
87
|
+
# Note: this will be called *once* per Python process! see __new__() method.
|
|
88
|
+
self.backend = backend
|
|
89
|
+
|
|
90
|
+
def __repr__(self):
|
|
91
|
+
return f'<{self.__class__.__name__} [{self.backend}]>'
|
|
92
|
+
|
|
60
93
|
def get(self, varname):
|
|
61
94
|
"""Get the current value of a setting."""
|
|
62
95
|
# TODO :: this is not an interface implementation...
|
|
@@ -76,15 +109,29 @@ class Instrument(ABC):
|
|
|
76
109
|
"""Set a variable to a new value."""
|
|
77
110
|
return self.backend.set(varname, value, unit='-')
|
|
78
111
|
|
|
79
|
-
_current_sourcefile = ''
|
|
80
|
-
|
|
81
112
|
def start_measurement(self, filename=''):
|
|
113
|
+
"""Start a new measurement.
|
|
114
|
+
|
|
115
|
+
'filename' is the filename of the datafile to write to. If left blank, start
|
|
116
|
+
a "quick measurement", for which IoniTOF writes to its default folder.
|
|
117
|
+
|
|
118
|
+
If pointing to a file and the file exist on the (local) server, this raises
|
|
119
|
+
an exception! To create unique filenames, use placeholders for year (%Y),
|
|
120
|
+
month (%m), and so on, for example `filename=D:/Sauerteig_%Y-%m-%d_%H-%M-%S.h5`.
|
|
121
|
+
The `filename` is passed through `strftime` with the current date and time.
|
|
122
|
+
|
|
123
|
+
see also:
|
|
124
|
+
"""
|
|
82
125
|
# this method must be implemented by each state
|
|
83
|
-
raise RuntimeError("can't start
|
|
126
|
+
raise RuntimeError("can't start <%s>" % type(self).__name__)
|
|
127
|
+
|
|
128
|
+
# (see also: this docstring)
|
|
129
|
+
start_measurement.__doc__ += time.strftime.__doc__
|
|
84
130
|
|
|
85
131
|
def stop_measurement(self):
|
|
132
|
+
"""Stop a running measurement."""
|
|
86
133
|
# this method must be implemented by each state
|
|
87
|
-
raise RuntimeError("can't stop
|
|
134
|
+
raise RuntimeError("can't stop <%s>" % type(self).__name__)
|
|
88
135
|
|
|
89
136
|
|
|
90
137
|
class _IdleInstrument(Instrument):
|
|
@@ -110,18 +157,12 @@ class _IdleInstrument(Instrument):
|
|
|
110
157
|
raise RuntimeError(f'filename exists and cannot be overwritten')
|
|
111
158
|
|
|
112
159
|
self.backend.start_measurement(filename)
|
|
113
|
-
self.
|
|
114
|
-
self._new_state(RunningInstrument)
|
|
115
|
-
|
|
116
|
-
return RunningMeasurement(self)
|
|
160
|
+
self._new_state(_RunningInstrument)
|
|
117
161
|
|
|
118
162
|
|
|
119
163
|
class _RunningInstrument(Instrument):
|
|
120
164
|
|
|
121
165
|
def stop_measurement(self):
|
|
122
166
|
self.backend.stop_measurement()
|
|
123
|
-
self._new_state(
|
|
124
|
-
|
|
125
|
-
# TODO :: this catches only one sourcefile.. it'll do for simple cases:
|
|
126
|
-
return FinishedMeasurement(self._current_sourcefile)
|
|
167
|
+
self._new_state(_IdleInstrument)
|
|
127
168
|
|
pytrms/peaktable.py
CHANGED
|
@@ -22,8 +22,7 @@ other custom formats.
|
|
|
22
22
|
... [
|
|
23
23
|
... {"label":"H3O+",
|
|
24
24
|
... "center":21.0219,
|
|
25
|
-
... "formula":"
|
|
26
|
-
... "parent":"?",
|
|
25
|
+
... "formula":"<unknown>",
|
|
27
26
|
... "isotopic_abundance":0.002,
|
|
28
27
|
... "k_rate":2.10,
|
|
29
28
|
... "multiplier":488
|
|
@@ -53,7 +52,7 @@ Peaks may be modified and the PeakTable exported in the same format:
|
|
|
53
52
|
"center": 21.0219,
|
|
54
53
|
"label": "H3O+",
|
|
55
54
|
"formula": "H3O",
|
|
56
|
-
"parent":
|
|
55
|
+
"parent": null,
|
|
57
56
|
"isotopic_abundance": 0.678,
|
|
58
57
|
"k_rate": 2.1,
|
|
59
58
|
"multiplier": 488.0,
|
|
@@ -107,7 +106,7 @@ class Peak:
|
|
|
107
106
|
"""
|
|
108
107
|
_exact_decimals = 4
|
|
109
108
|
|
|
110
|
-
def __init__(self, center, label='', formula='', parent=None, borders=(),
|
|
109
|
+
def __init__(self, center, label='', *, formula='', parent=None, borders=(),
|
|
111
110
|
isotopic_abundance=1.0, k_rate=2.0, multiplier=1.0,
|
|
112
111
|
resolution=1000, shift=0):
|
|
113
112
|
self.center = round(float(center), ndigits=Peak._exact_decimals)
|
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
pytrms/__init__.py,sha256=
|
|
1
|
+
pytrms/__init__.py,sha256=znPgfmjFqRfx_UoKYLhpNTtDlMVqrQc8cmYHwigABaY,2821
|
|
2
2
|
pytrms/_base/__init__.py,sha256=2KUO7R_SPZxsV0Xe_Hy4e2HnQI5zDzOYJc4H7G3ZQpE,850
|
|
3
3
|
pytrms/_base/ioniclient.py,sha256=eTkksDwB8u9i-rnwCGH7o8rXcNbez-QakXLqQFJJCw0,1368
|
|
4
4
|
pytrms/_base/mqttclient.py,sha256=DxETIjK_qIvxCGiHLzHXdvyGcCgiwYfggWlXrxzwpqY,5347
|
|
5
5
|
pytrms/_version.py,sha256=yRCN1kvPaX0FHycK0NBHTluhkf5euj8U7WNTKdVQafg,828
|
|
6
|
-
pytrms/clients/__init__.py,sha256=
|
|
7
|
-
pytrms/clients/db_api.py,sha256=
|
|
8
|
-
pytrms/clients/dummy.py,sha256=
|
|
6
|
+
pytrms/clients/__init__.py,sha256=_IHR-GMeAemtrkeQR5I4lPvO9wVR0pEWuswgdi4Rw0I,263
|
|
7
|
+
pytrms/clients/db_api.py,sha256=ZZuKz1PrWPL5Zu4FFgXY1Ovz7f1Evt_Kt1cDRP51sHs,17488
|
|
8
|
+
pytrms/clients/dummy.py,sha256=BaCAhR30eE-ULu8U0bE-7L_cTdAeWsW0Dn1C_cwiCjw,1147
|
|
9
9
|
pytrms/clients/ioniclient.py,sha256=cp37XSoCzLamjDzcUCrKbp530YMstQ0Eoj81r5tClL8,2390
|
|
10
10
|
pytrms/clients/modbus.py,sha256=Mgxps17amE033P6cCJ5Bv4r8vdWZC2SiyxqfQ7KfEhA,26219
|
|
11
|
-
pytrms/clients/mqtt.py,sha256=
|
|
12
|
-
pytrms/clients/ssevent.py,sha256=
|
|
11
|
+
pytrms/clients/mqtt.py,sha256=ybtRbKeRfxz2bPOgOtlJDAkCP79fWEu47z7pliZHAKQ,31809
|
|
12
|
+
pytrms/clients/ssevent.py,sha256=zj5LPkIbGm3APOJuol9GPc_A_zsas6gjY-w9sM-LQwo,4354
|
|
13
13
|
pytrms/compose/__init__.py,sha256=hbjX-rzFlBnZpp7OP8edyZAw0r2-im1MtWxQNU8tG_A,28
|
|
14
14
|
pytrms/compose/composition.py,sha256=VU0E50zIdqS4cIzTdMMDxWWCUd5jkLbuPcNAET93oqo,11779
|
|
15
15
|
pytrms/data/IoniTofPrefs.ini,sha256=BGyTijnmtdGqa-kGbAuB1RysG4MPK9nlF9TR069tKwE,1887
|
|
16
|
-
pytrms/data/ParaIDs.csv,sha256=
|
|
16
|
+
pytrms/data/ParaIDs.csv,sha256=CWOXx83OTmHRgsl39Si35DwND94UnvhdIrkekrnbpNg,27401
|
|
17
17
|
pytrms/helpers.py,sha256=0GhSlX4zwMIQqNx2-G7WrLecoYIG7MVFZb5doODaenA,4985
|
|
18
|
-
pytrms/instrument.py,sha256=
|
|
18
|
+
pytrms/instrument.py,sha256=x0lBK5O9BBLNdRAZMnc4DGsQSCQe0Lu-WH5pgNGkoDI,5824
|
|
19
19
|
pytrms/measurement.py,sha256=iHsEWmJhCSFxMON_N-5mtCmFqigBqGQM4AImqE7pS44,7629
|
|
20
|
-
pytrms/peaktable.py,sha256=
|
|
20
|
+
pytrms/peaktable.py,sha256=w76wPgVARQH8bOppDejpvVHjSDY88ZZYkGBMmgRFmTM,17195
|
|
21
21
|
pytrms/plotting/__init__.py,sha256=sfL4k2PeBmzIf-5Z_2rnkeM8As3psbPxaVvmpi1T48Q,62
|
|
22
22
|
pytrms/plotting/plotting.py,sha256=WzP4Is2PSu_NdhY73HsCU8iEHmLgnIgNY-opsxwIjH8,675
|
|
23
23
|
pytrms/readers/__init__.py,sha256=2r9dXwPRqYkIGpM0EjY-m_Ti3qtxE0jeC9FnudDDoXc,72
|
|
24
24
|
pytrms/readers/ionitof_reader.py,sha256=c8W5aglrY1bmuljnaAqx6TGWAPGXXMkuHjJ1aRw3Wrc,17624
|
|
25
|
-
pytrms-0.9.
|
|
26
|
-
pytrms-0.9.
|
|
27
|
-
pytrms-0.9.
|
|
28
|
-
pytrms-0.9.
|
|
25
|
+
pytrms-0.9.8.dist-info/LICENSE,sha256=gXf5dRMhNSbfLPYYTY_5hsZ1r7UU1OaKQEAQUhuIBkM,18092
|
|
26
|
+
pytrms-0.9.8.dist-info/METADATA,sha256=ZA7Pb3jx131IEc0pjISnvg6vpXlmiPdGoIi3Uot7hJM,812
|
|
27
|
+
pytrms-0.9.8.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
28
|
+
pytrms-0.9.8.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|