pytrms 0.9.1__tar.gz → 0.9.2__tar.gz
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-0.9.1 → pytrms-0.9.2}/PKG-INFO +2 -2
- {pytrms-0.9.1 → pytrms-0.9.2}/pyproject.toml +4 -2
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/__init__.py +8 -6
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/_base/mqttclient.py +20 -7
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/clients/__init__.py +2 -2
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/clients/db_api.py +5 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/clients/mqtt.py +6 -2
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/clients/ssevent.py +14 -9
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/instrument.py +22 -17
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/measurement.py +10 -8
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/readers/ionitof_reader.py +6 -6
- {pytrms-0.9.1 → pytrms-0.9.2}/LICENSE +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/_base/__init__.py +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/_base/ioniclient.py +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/_version.py +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/clients/ioniclient.py +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/clients/modbus.py +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/compose/__init__.py +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/compose/composition.py +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/data/IoniTofPrefs.ini +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/data/ParaIDs.csv +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/helpers.py +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/peaktable.py +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/plotting/__init__.py +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/plotting/plotting.py +0 -0
- {pytrms-0.9.1 → pytrms-0.9.2}/pytrms/readers/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: pytrms
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: Python bundle for proton-transfer reaction mass-spectrometry (PTR-MS).
|
|
5
5
|
License: GPL-2.0
|
|
6
6
|
Author: Moritz Koenemann
|
|
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Requires-Dist: h5py (>=3.12.1,<4.0.0)
|
|
15
15
|
Requires-Dist: matplotlib (>=3.9.2,<4.0.0)
|
|
16
|
-
Requires-Dist: paho-mqtt (>=1.6.1,<
|
|
16
|
+
Requires-Dist: paho-mqtt (>=1.6.1,<3.0)
|
|
17
17
|
Requires-Dist: pandas (>=2.2.3,<3.0.0)
|
|
18
18
|
Requires-Dist: pyModbusTCP (>=0.1.9)
|
|
19
19
|
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "pytrms"
|
|
3
|
-
version = "0.9.
|
|
3
|
+
version = "0.9.2"
|
|
4
4
|
description = "Python bundle for proton-transfer reaction mass-spectrometry (PTR-MS)."
|
|
5
5
|
authors = ["Moritz Koenemann <moritz.koenemann@ionicon.com>"]
|
|
6
6
|
license = "GPL-2.0"
|
|
@@ -15,8 +15,10 @@ h5py = "^3.12.1"
|
|
|
15
15
|
matplotlib = "^3.9.2"
|
|
16
16
|
requests = "^2.32.3"
|
|
17
17
|
pandas = "^2.2.3"
|
|
18
|
+
|
|
19
|
+
# we have legacy support for Anaconda-packages!
|
|
18
20
|
pyModbusTCP = ">=0.1.9"
|
|
19
|
-
paho-mqtt = "
|
|
21
|
+
paho-mqtt = ">=1.6.1,<3.0"
|
|
20
22
|
|
|
21
23
|
[tool.poetry.group.test.dependencies]
|
|
22
24
|
pytest = "^8.3.0"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
_version = '0.9.
|
|
1
|
+
_version = '0.9.2'
|
|
2
2
|
|
|
3
3
|
__all__ = ['load', 'connect']
|
|
4
4
|
|
|
@@ -6,14 +6,16 @@ __all__ = ['load', 'connect']
|
|
|
6
6
|
def load(path):
|
|
7
7
|
'''Open a datafile for post-analysis or batch processing.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
`path` may be a glob-expression to collect a whole batch.
|
|
10
|
+
|
|
11
|
+
returns a `Measurement` instance.
|
|
10
12
|
'''
|
|
11
|
-
|
|
12
|
-
from .
|
|
13
|
+
import glob
|
|
14
|
+
from .measurement import FinishedMeasurement
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
files = glob.glob(path)
|
|
15
17
|
|
|
16
|
-
return
|
|
18
|
+
return FinishedMeasurement(*files)
|
|
17
19
|
|
|
18
20
|
def connect(host=None, method='webapi'):
|
|
19
21
|
'''Connect a client to a running measurement server.
|
|
@@ -6,9 +6,8 @@ from collections import deque
|
|
|
6
6
|
from itertools import cycle
|
|
7
7
|
from threading import Condition, RLock
|
|
8
8
|
from datetime import datetime as dt
|
|
9
|
-
from abc import ABC, abstractmethod
|
|
10
9
|
|
|
11
|
-
import paho.mqtt.client
|
|
10
|
+
import paho.mqtt.client
|
|
12
11
|
|
|
13
12
|
from .ioniclient import IoniClientBase
|
|
14
13
|
|
|
@@ -39,7 +38,6 @@ def _on_publish(client, self, mid):
|
|
|
39
38
|
class MqttClientBase(IoniClientBase):
|
|
40
39
|
|
|
41
40
|
@property
|
|
42
|
-
@abstractmethod
|
|
43
41
|
def is_connected(self):
|
|
44
42
|
'''Returns `True` if connected to the server.
|
|
45
43
|
|
|
@@ -49,12 +47,27 @@ class MqttClientBase(IoniClientBase):
|
|
|
49
47
|
return (True
|
|
50
48
|
and self.client.is_connected())
|
|
51
49
|
|
|
52
|
-
def __init__(self, host, subscriber_functions,
|
|
50
|
+
def __init__(self, host, port, subscriber_functions,
|
|
53
51
|
on_connect, on_subscribe, on_publish,
|
|
54
52
|
connect_timeout_s=10):
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
assert len(subscriber_functions) > 0, "no subscribers: for some unknown reason this causes disconnects"
|
|
54
|
+
super().__init__(host, port)
|
|
55
|
+
|
|
56
|
+
# Note: Version 2.0 of paho-mqtt introduced versioning of the user-callback to fix
|
|
57
|
+
# some inconsistency in callback arguments and to provide better support for MQTTv5.
|
|
58
|
+
# VERSION1 of the callback is deprecated, but is still supported in version 2.x.
|
|
59
|
+
# If you want to upgrade to the newer version of the API callback, you will need
|
|
60
|
+
# to update your callbacks:
|
|
61
|
+
paho_version = int(paho.mqtt.__version__.split('.')[0])
|
|
62
|
+
if paho_version == 1:
|
|
63
|
+
self.client = paho.mqtt.client.Client(clean_session=True)
|
|
64
|
+
elif paho_version == 2:
|
|
65
|
+
self.client = paho.mqtt.client.Client(paho.mqtt.client.CallbackAPIVersion.VERSION1,
|
|
66
|
+
clean_session=True)
|
|
67
|
+
else:
|
|
68
|
+
# see https://eclipse.dev/paho/files/paho.mqtt.python/html/migrations.html
|
|
69
|
+
raise NotImplementedError("API VERSION2 for MQTTv5 (use paho-mqtt 2.x or implement user callbacks)")
|
|
70
|
+
|
|
58
71
|
# clean_session is a boolean that determines the client type. If True,
|
|
59
72
|
# the broker will remove all information about this client when it
|
|
60
73
|
# disconnects. If False, the client is a persistent client and
|
|
@@ -7,7 +7,7 @@ assert os.path.exists(_par_id_file), "par-id file not found: please re-install P
|
|
|
7
7
|
|
|
8
8
|
import logging as _logging
|
|
9
9
|
|
|
10
|
-
_logging.TRACE =
|
|
10
|
+
_logging.TRACE = 5 # even more verbose than logging.DEBUG
|
|
11
11
|
|
|
12
12
|
def enable_extended_logging(log_level=_logging.DEBUG):
|
|
13
13
|
'''make output of http-requests more talkative.
|
|
@@ -22,7 +22,7 @@ def enable_extended_logging(log_level=_logging.DEBUG):
|
|
|
22
22
|
requests_log.setLevel(log_level)
|
|
23
23
|
requests_log.propagate = True
|
|
24
24
|
|
|
25
|
-
if log_level
|
|
25
|
+
if log_level <= _logging.TRACE:
|
|
26
26
|
# Enabling debugging at http.client level (requests->urllib3->http.client)
|
|
27
27
|
# you will see the REQUEST, including HEADERS and DATA, and RESPONSE with
|
|
28
28
|
# HEADERS but without DATA. the only thing missing will be the response.body,
|
|
@@ -172,6 +172,11 @@ class IoniConnect(IoniClientBase):
|
|
|
172
172
|
"""Follow the server-sent-events (SSE) on the DB-API.
|
|
173
173
|
|
|
174
174
|
`event_re` a regular expression to filter events (default: matches everything)
|
|
175
|
+
|
|
176
|
+
Note: This will block until a matching event is received.
|
|
177
|
+
Especially, it cannot be cancelled by KeyboardInterrupt (due to the `requests`
|
|
178
|
+
stream-implementation), unless the server sends a keep-alive at regular
|
|
179
|
+
intervals (as every well-behaved server should be doing)!
|
|
175
180
|
"""
|
|
176
181
|
yield from SSEventListener(event_re, host_url=self.url, endpoint="/api/events",
|
|
177
182
|
session=self.session)
|
|
@@ -361,6 +361,10 @@ def follow_act_set_values(client, self, msg):
|
|
|
361
361
|
# Note: this topic doesn't strictly follow the convention and is handled separately
|
|
362
362
|
return
|
|
363
363
|
|
|
364
|
+
if server == "Sequencer":
|
|
365
|
+
# Note: this is a separate program and will be ignored (has its own AUTO_-numbers et.c.)
|
|
366
|
+
return
|
|
367
|
+
|
|
364
368
|
if parID == "PTR_CalcConzInfo":
|
|
365
369
|
# another "special" topic handled in 'follow_calc_conz_info' ...
|
|
366
370
|
return
|
|
@@ -497,9 +501,9 @@ class MqttClient(MqttClientBase):
|
|
|
497
501
|
return self._overallcycle[0]
|
|
498
502
|
return 0
|
|
499
503
|
|
|
500
|
-
def __init__(self, host='127.0.0.1'):
|
|
504
|
+
def __init__(self, host='127.0.0.1', port=1883):
|
|
501
505
|
# this sets up the mqtt connection with default callbacks:
|
|
502
|
-
super().__init__(host, _subscriber_functions, None, None, None)
|
|
506
|
+
super().__init__(host, port, _subscriber_functions, None, None, None)
|
|
503
507
|
log.debug(f"connection check ({self.is_connected}) :: {self._server_state = } / {self._sched_cmds = }");
|
|
504
508
|
|
|
505
509
|
def disconnect(self):
|
|
@@ -6,7 +6,7 @@ import requests
|
|
|
6
6
|
|
|
7
7
|
from . import _logging
|
|
8
8
|
|
|
9
|
-
log = _logging.getLogger()
|
|
9
|
+
log = _logging.getLogger(__name__)
|
|
10
10
|
|
|
11
11
|
_event_rv = namedtuple('ssevent', ['event', 'data'])
|
|
12
12
|
|
|
@@ -19,7 +19,7 @@ class SSEventListener(Iterable):
|
|
|
19
19
|
for bite in response.iter_content(chunk_size=1, decode_unicode=True):
|
|
20
20
|
line += bite
|
|
21
21
|
if bite == '\n':
|
|
22
|
-
yield line
|
|
22
|
+
yield line.strip()
|
|
23
23
|
line = ''
|
|
24
24
|
|
|
25
25
|
def __init__(self, event_re=None, host_url='http://127.0.0.1:5066',
|
|
@@ -59,19 +59,24 @@ class SSEventListener(Iterable):
|
|
|
59
59
|
|
|
60
60
|
event = msg = ''
|
|
61
61
|
for line in self._line_stream(self._connect_response): # blocks...
|
|
62
|
-
if not line
|
|
62
|
+
if not line:
|
|
63
|
+
# an empty line concludes an event
|
|
63
64
|
if event and any(re.match(sub, event) for sub in self.subscriptions):
|
|
64
65
|
yield _event_rv(event, msg)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
|
|
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 = ''
|
|
68
70
|
continue
|
|
69
71
|
|
|
70
72
|
key, val = line.split(':', maxsplit=1)
|
|
71
|
-
if key
|
|
72
|
-
|
|
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()
|
|
73
78
|
elif key == 'data':
|
|
74
|
-
msg += val.
|
|
79
|
+
msg += val.lstrip()
|
|
75
80
|
else:
|
|
76
81
|
log.warning(f"unknown SSE-key <{key}> in stream")
|
|
77
82
|
|
|
@@ -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
|
+
|
|
@@ -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
|
|
|
@@ -70,7 +70,7 @@ class IoniTOFReader:
|
|
|
70
70
|
# well it didn't work..
|
|
71
71
|
pass
|
|
72
72
|
finally:
|
|
73
|
-
self.hf = h5py.File(path, 'r', swmr=
|
|
73
|
+
self.hf = h5py.File(path, 'r', swmr=False)
|
|
74
74
|
|
|
75
75
|
@property
|
|
76
76
|
def number_of_timebins(self):
|
|
@@ -97,7 +97,7 @@ class IoniTOFReader:
|
|
|
97
97
|
return float(self.hf.attrs.get('Single Spec Duration (ms)'))
|
|
98
98
|
|
|
99
99
|
def __init__(self, path):
|
|
100
|
-
self.hf = h5py.File(path, 'r', swmr=
|
|
100
|
+
self.hf = h5py.File(path, 'r', swmr=False)
|
|
101
101
|
self.filename = os.path.abspath(self.hf.filename)
|
|
102
102
|
|
|
103
103
|
table_locs = {
|
|
@@ -439,14 +439,14 @@ class IoniTOFReader:
|
|
|
439
439
|
raise ValueError(msg) from exc
|
|
440
440
|
|
|
441
441
|
try:
|
|
442
|
-
data = self._read_datainfo(tracedata, prefix=prefix)
|
|
443
|
-
pt = self._read_datainfo(tracedata, prefix='PeakTable')
|
|
444
|
-
labels = [b.decode('latin1') for b in pt['label']]
|
|
442
|
+
data = self._read_datainfo(tracedata, prefix=prefix)
|
|
443
|
+
pt = self._read_datainfo(tracedata, prefix='PeakTable')
|
|
445
444
|
except KeyError as exc:
|
|
446
445
|
raise KeyError(f'unknown group {exc}. filetype is not supported yet.') from exc
|
|
447
446
|
|
|
447
|
+
labels = [b.decode('latin1') for b in pt['label']]
|
|
448
448
|
mapper = dict(zip(data.columns, labels))
|
|
449
|
-
data.rename(columns=mapper)
|
|
449
|
+
data.rename(columns=mapper, inplace=True)
|
|
450
450
|
data.index = list(self.iter_index(index))
|
|
451
451
|
|
|
452
452
|
return data
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|