pytrms 0.9.0__py3-none-any.whl → 0.9.1__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 CHANGED
@@ -1,4 +1,4 @@
1
- _version = '0.9.0'
1
+ _version = '0.9.1'
2
2
 
3
3
  __all__ = ['load', 'connect']
4
4
 
@@ -23,7 +23,6 @@ def connect(host=None, method='webapi'):
23
23
  returns an `Instrument` if connected successfully.
24
24
  '''
25
25
  from .instrument import Instrument
26
- from .helpers import PTRConnectionError
27
26
 
28
27
  if method.lower() == 'webapi':
29
28
  from .clients.ioniclient import IoniClient
pytrms/clients/db_api.py CHANGED
@@ -4,6 +4,7 @@ import json
4
4
  import requests
5
5
 
6
6
  from . import _logging
7
+ from .ssevent import SSEventListener
7
8
  from .._base import IoniClientBase
8
9
 
9
10
  log = _logging.getLogger(__name__)
@@ -82,7 +83,7 @@ class IoniConnect(IoniClientBase):
82
83
  if not endpoint.startswith('/'):
83
84
  endpoint = '/' + endpoint
84
85
  if not isinstance(data, str):
85
- data = json.dumps(data)
86
+ data = json.dumps(data, ensure_ascii=False) # default is `True`, escapes Umlaute!
86
87
  if 'headers' not in kwargs:
87
88
  kwargs['headers'] = {'content-type': 'application/hal+json'}
88
89
  elif 'content-type' not in (k.lower() for k in kwargs['headers']):
@@ -94,93 +95,84 @@ class IoniConnect(IoniClientBase):
94
95
 
95
96
  return r
96
97
 
97
- def iter_events(self):
98
- """Follow the server-sent-events (SSE) on the DB-API."""
99
- r = self.session.request('GET', self.url + "/api/events",
100
- headers={'accept': 'text/event-stream'}, stream=True)
101
- r.raise_for_status()
102
- kv_pair = dict()
103
- for line in r.iter_lines():
104
- # empty newlines serve as keep-alive and end-of-entry:
105
- if not line:
106
- if kv_pair:
107
- yield kv_pair
108
- kv_pair = dict()
109
- else:
110
- log.debug("sse: still kept alive...")
111
- continue
112
-
113
- key, val = line.decode().split(':')
114
- kv_pair[key] = val.strip()
115
-
116
- def refresh_comp_dict(self):
117
- j = self.get('/api/components')
118
- self.comp_dict = {component["shortName"]: component
119
- for component in j["_embedded"]["components"]}
120
-
121
- def get_component(self, short_name):
122
- if not len(self.comp_dict):
123
- self.refresh_comp_dict()
124
-
125
- return self.comp_dict[short_name]
126
-
127
- def create_component(self, short_name):
128
- payload = {
129
- "shortName": short_name
98
+ def sync(self, peaktable):
99
+ """Compare and upload any differences in `peaktable` to the database."""
100
+ from pytrms.peaktable import Peak, PeakTable
101
+ from operator import attrgetter
102
+
103
+ # Note: a `Peak` is a hashable object that serves as a key that
104
+ # distinguishes between peaks as defined by PyTRMS:
105
+ make_key = lambda peak: Peak(center=peak['center'], label=peak['name'], shift=peak['shift'])
106
+
107
+ if isinstance(peaktable, str):
108
+ log.info(f"loading peaktable '{peaktable}'...")
109
+ peaktable = PeakTable.from_file(peaktable)
110
+
111
+ # get the PyTRMS- and IoniConnect-peaks on the same page:
112
+ conv = {
113
+ 'name': attrgetter('label'),
114
+ 'center': attrgetter('center'),
115
+ 'kRate': attrgetter('k_rate'),
116
+ 'low': lambda p: p.borders[0],
117
+ 'high': lambda p: p.borders[1],
118
+ 'shift': attrgetter('shift'),
119
+ 'multiplier': attrgetter('multiplier'),
130
120
  }
131
- self.post('/api/components', payload)
132
- self.refresh_comp_dict()
133
-
134
- def create_average(self, endpoint, run, step, action=0, use_mean=True):
135
-
136
- params = {'run': int(run), 'step': int(step), 'usemean': bool(use_mean)}
137
- if (action != 0):
138
- params['action'] = int(action)
139
-
140
- timecycles = self.get(endpoint, params)
141
- self.current_avg_endpoint = self.post('/api/averages', timecycles)
142
-
143
- def save_component_values(self, new_values):
144
- """
145
- Post Components to the database.
121
+ # normalize the input argument and create a hashable set:
122
+ updates = dict()
123
+ for peak in peaktable:
124
+ payload = {k: conv[k](peak) for k in conv}
125
+ updates[make_key(payload)] = {'payload': payload}
126
+
127
+ log.info(f"fetching current peaktable from the server...")
128
+ # create a comparable collection of peaks already on the database by
129
+ # reducing the keys in the response to what we actually want to update:
130
+ db_peaks = {make_key(p): {
131
+ 'payload': {k: p[k] for k in conv.keys()},
132
+ 'self': p['_links']['self'],
133
+ 'parent': p['_links'].get('parent'),
134
+ } for p in self.get('/api/peaks')['_embedded']['peaks']}
135
+
136
+ to_update = updates.keys() & db_peaks.keys()
137
+ to_upload = updates.keys() - db_peaks.keys()
138
+ updated = 0
139
+ for key in sorted(to_update):
140
+ # check if an existing peak needs an update
141
+ if db_peaks[key]['payload'] == updates[key]['payload']:
142
+ # nothing to do..
143
+ log.debug(f"up-to-date: {key}")
144
+ continue
146
145
 
147
- `new_values` dictionary {name~>value}
148
- """
149
- if self.current_avg_endpoint is None:
150
- raise Exception("create average first")
151
-
152
- payload = {
153
- "quantities": [
154
- {
155
- "componentID": self.get_component(name)["componentID"],
156
- "value": value
157
- } for name, value in new_values.items()
158
- ]
146
+ self.put(db_peaks[key]['self']['href'], updates[key]['payload'])
147
+ log.info(f"updated: {key}")
148
+ updated += 1
149
+
150
+ if len(to_upload):
151
+ # Note: POSTing the embedded-collection is *miles faster*
152
+ # than doing separate requests for each peak!
153
+ payload = {'_embedded': {'peaks': [updates[key]['payload'] for key in sorted(to_upload)]}}
154
+ self.post('/api/peaks', payload)
155
+ for key in sorted(to_upload): log.info(f"added new: {key}")
156
+
157
+ # Note: this disregards the peak-parent-relationship, but in
158
+ # order to implement this correctly, one would need to check
159
+ # if the parent-peak with a specific 'parentID' is already
160
+ # uploaded and search it.. there's an endpoint
161
+ # 'LINK /api/peaks/{parentID} Location: /api/peaks/{childID}'
162
+ # to link a child to its parent, but it remains complicated.
163
+ # TODO :: maybe later implement parent-peaks!?
164
+
165
+ return {
166
+ 'added': len(to_upload),
167
+ 'updated': updated,
168
+ 'up-to-date': len(to_update) - updated,
159
169
  }
160
- endpoint = self.current_avg_endpoint + '/component_traces'
161
- self.put(endpoint, payload)
162
170
 
163
- def save_instrument_values(self, new_values):
164
- """
165
- Post Parameters to the database.
171
+ def iter_events(self, event_re=r".*"):
172
+ """Follow the server-sent-events (SSE) on the DB-API.
166
173
 
167
- `new_values` dictionary {name~>value}
174
+ `event_re` a regular expression to filter events (default: matches everything)
168
175
  """
169
- # 13.07.: SCHNELL, SCHNELL (es ist 17 Uhr 57 und ich will die Modbus-instrument
170
- # daten noch hochladen):
171
- # this expects a namedtuple as defined in Modbus client: .set, .act, .par_id
172
- if self.current_avg_endpoint is None:
173
- raise Exception("create average first")
174
-
175
- payload = {
176
- "quantities": [
177
- {
178
- "parameterID": item.par_id,
179
- "setValue": item.set,
180
- "actMean": item.act
181
- } for name, item in new_values.items()
182
- ]
183
- }
184
- endpoint = self.current_avg_endpoint + '/parameter_traces' # on the DB it's called parameter... :\
185
- self.put(endpoint, payload)
176
+ yield from SSEventListener(event_re, host_url=self.url, endpoint="/api/events",
177
+ session=self.session)
186
178
 
@@ -18,17 +18,17 @@ class IoniClient:
18
18
  Access the Ionicon WebAPI.
19
19
 
20
20
  Usage:
21
- >>> client = IoniClient()
22
- >>> client.get('TPS_Pull_H')
21
+ > client = IoniClient()
22
+ > client.get('TPS_Pull_H')
23
23
  {'TPS_Pull_H': 123.45, ... }
24
24
 
25
- >>> client.set('TPS_Pull_H', 42)
25
+ > client.set('TPS_Pull_H', 42)
26
26
  {'TPS_Pull_H': 42.0, ... }
27
27
 
28
- >>> client.start_measurement()
28
+ > client.start_measurement()
29
29
  ACK
30
30
 
31
- >>> client.host, client.port
31
+ > client.host, client.port
32
32
  ('localhost', 8002)
33
33
 
34
34
  '''
@@ -85,19 +85,3 @@ class IoniClient:
85
85
 
86
86
  def stop_measurement(self):
87
87
  return self.set('ACQ_SRV_Stop_Meas', 1)
88
-
89
-
90
- if __name__ == '__main__':
91
- import sys
92
- client = IoniClient()
93
-
94
- if len(sys.argv) == 2:
95
- print(client.get(sys.argv[1]))
96
- elif len(sys.argv) == 3:
97
- print(client.set(sys.argv[1], sys.argv[2]))
98
- else:
99
- print(f"""\
100
- usage:
101
- python {sys.argv[0]} <varname> [<value>]
102
- """)
103
-
pytrms/clients/modbus.py CHANGED
@@ -9,7 +9,7 @@ from collections import namedtuple
9
9
  from functools import lru_cache
10
10
  from itertools import tee
11
11
 
12
- from pyModbusTCP import client
12
+ import pyModbusTCP.client
13
13
 
14
14
  from . import _par_id_file
15
15
  from .._base.ioniclient import IoniClientBase
@@ -19,9 +19,28 @@ log = logging.getLogger(__name__)
19
19
  __all__ = ['IoniconModbus']
20
20
 
21
21
 
22
+ def _patch_is_open():
23
+ # Note: the .is_open and .timeout attributes were changed
24
+ # from a function to a property!
25
+ #
26
+ # 0.2.0 2022-06-05
27
+ #
28
+ # - ModbusClient: parameters are now properties instead of methods (more intuitive).
29
+ #
30
+ # from the [changelog](https://github.com/sourceperl/pyModbusTCP/blob/master/CHANGES):
31
+ major, minor, patch = pyModbusTCP.__version__.split('.')
32
+ if int(minor) < 2:
33
+ return lambda mc: mc.is_open()
34
+ else:
35
+ return lambda mc: mc.is_open
36
+
37
+ _is_open = _patch_is_open()
38
+
22
39
  with open(_par_id_file) as f:
23
40
  it = iter(f)
24
- assert next(it).startswith('ID\tName'), "Modbus parameter file is corrupt: " + f.name
41
+ assert next(it).startswith('ID\tName'), ("Modbus parameter file is corrupt: "
42
+ + f.name
43
+ + "\n\ntry re-installing the PyTRMS python package to fix it!")
25
44
  _id_to_descr = {int(id_): name for id_, name, *_ in (line.strip().split('\t') for line in it)}
26
45
 
27
46
  # look-up-table for c_structs (see docstring of struct-module for more info).
@@ -59,10 +78,10 @@ def _unpack(registers, format='>f'):
59
78
  representation, respectively.
60
79
 
61
80
  >>> _unpack([17448, 0], 'float')
62
- 672.
81
+ 672.0
63
82
 
64
83
  >>> _unpack([17446, 32768], 'float')
65
- 666.
84
+ 666.0
66
85
 
67
86
  >>> _unpack([16875, 61191, 54426, 37896], 'double')
68
87
  3749199524.83057
@@ -132,7 +151,7 @@ class IoniconModbus(IoniClientBase):
132
151
 
133
152
  @property
134
153
  def is_connected(self):
135
- if not self.mc.is_open:
154
+ if not _is_open(self.mc):
136
155
  return False
137
156
 
138
157
  # wait for the IoniTOF alive-counter to change (1 second max)...
@@ -175,7 +194,18 @@ class IoniconModbus(IoniClientBase):
175
194
 
176
195
  def __init__(self, host='localhost', port=502):
177
196
  super().__init__(host, port)
178
- self.mc = client.ModbusClient(host=self.host, port=self.port)
197
+ # Note: we patch the behaviour such, that it behaves like pre-0.2
198
+ # (from the time of development of this module), BUT we skip the
199
+ # auto_close-feature for the sake of speed:
200
+ #
201
+ # 0.2.0 2022-06-05
202
+ #
203
+ # - ModbusClient: now TCP auto open mode is active by default (auto_open=True, auto_close=False).
204
+ #
205
+ # from the [changelog](https://github.com/sourceperl/pyModbusTCP/blob/master/CHANGES)
206
+ self.mc = pyModbusTCP.client.ModbusClient(host=self.host, port=self.port,
207
+ auto_open = False, auto_close = False
208
+ )
179
209
  # try connect immediately:
180
210
  try:
181
211
  self.connect()
@@ -185,8 +215,11 @@ class IoniconModbus(IoniClientBase):
185
215
 
186
216
  def connect(self, timeout_s=10):
187
217
  log.info(f"[{self}] connecting to Modbus server...")
188
- self.mc.timeout = timeout_s
189
- self.mc.auto_open = True
218
+ # Note: .timeout-attribute changed to a property with 0.2.0 (see comments above)
219
+ if callable(self.mc.timeout):
220
+ self.mc.timeout(timeout_s)
221
+ else:
222
+ self.mc.timeout = timeout_s
190
223
  if not self.mc.open():
191
224
  raise TimeoutError(f"[{self}] no connection to modbus socket")
192
225
 
@@ -201,7 +234,7 @@ class IoniconModbus(IoniClientBase):
201
234
  raise TimeoutError(f"[{self}] no connection to IoniTOF");
202
235
 
203
236
  def disconnect(self):
204
- if self.mc.is_open:
237
+ if _is_open(self.mc):
205
238
  self.mc.close()
206
239
 
207
240
  @property
@@ -452,9 +485,9 @@ class IoniconModbus(IoniClientBase):
452
485
  _read = self.mc.read_holding_registers if is_holding_register else self.mc.read_input_registers
453
486
 
454
487
  register = _read(addr, n_bytes)
455
- if register is None and self.mc.is_open:
488
+ if register is None and _is_open(self.mc):
456
489
  raise IOError(f"unable to read ({n_bytes}) registers at [{addr}] from connection")
457
- elif register is None and not self.mc.is_open:
490
+ elif register is None and not _is_open(self.mc):
458
491
  raise IOError("trying to read from closed Modbus-connection")
459
492
 
460
493
  return _unpack(register, c_format)
pytrms/clients/mqtt.py CHANGED
@@ -325,9 +325,14 @@ def follow_state(client, self, msg):
325
325
  log.debug(f"[{self}] new server-state: " + str(state))
326
326
  # replace the current state with the new element:
327
327
  self._server_state.append(state)
328
- if state == "ACQ_Aquire": # yes, there's a typo, plz keep it :)
329
- self._calcconzinfo.append(_NOT_INIT) # invalidate
330
- # Note: this signals to the relevant thread that we need an update
328
+ meas_running = (state == "ACQ_Aquire") # yes, there's a typo, plz keep it :)
329
+ just_started = (meas_running and not msg.retain)
330
+ if meas_running:
331
+ # signal the relevant thread(s) that we need an update:
332
+ self._calcconzinfo.append(_NOT_INIT)
333
+ if just_started:
334
+ # invalidate the source-file until we get a new one:
335
+ self._sf_filename.append(_NOT_INIT)
331
336
 
332
337
  follow_state.topics = ["DataCollection/Act/ACQ_SRV_CurrentState"]
333
338
 
@@ -404,8 +409,8 @@ _NOT_INIT = object()
404
409
  class MqttClient(MqttClientBase):
405
410
  """a simplified client for the Ionicon MQTT API.
406
411
 
407
- >>> mq = MqttClient()
408
- >>> mq.write('TCP_MCP_B', 3400)
412
+ > mq = MqttClient()
413
+ > mq.write('TCP_MCP_B', 3400)
409
414
  ValueError()
410
415
 
411
416
  """
@@ -463,11 +468,27 @@ class MqttClient(MqttClientBase):
463
468
 
464
469
  @property
465
470
  def current_sourcefile(self):
466
- '''Returns the path to the hdf5-file that is currently (or soon to be) written.
471
+ '''Returns the path to the hdf5-file that is currently being written.
467
472
 
468
- May be an empty string if no sourcefile has yet been set.
473
+ Returns an empty string if no measurement is running.
469
474
  '''
470
- return self._sf_filename[0]
475
+ if not self.is_running:
476
+ return ""
477
+
478
+ if self._sf_filename[0] is not _NOT_INIT:
479
+ return self._sf_filename[0]
480
+
481
+ # Note: '_NOT_INIT' is set by us on start of acquisition, so we'd expect
482
+ # to receive the source-file-topic after a (generous) timeout:
483
+ timeout_s = 15
484
+ started_at = time.monotonic()
485
+ while time.monotonic() < started_at + timeout_s:
486
+ if self._sf_filename[0] is not _NOT_INIT:
487
+ return self._sf_filename[0]
488
+
489
+ time.sleep(10e-3)
490
+ else:
491
+ raise TimeoutError(f"[{self}] unable to retrieve source-file after ({timeout_s = })");
471
492
 
472
493
  @property
473
494
  def current_cycle(self):
@@ -508,12 +529,10 @@ class MqttClient(MqttClientBase):
508
529
  if _lut is self.set_values and is_read_only:
509
530
  raise ValueError(f"'{parID}' is read-only, did you mean `kind='act'`?")
510
531
 
511
- # Note: The values should need NO! time to be populated from the MQTT topics,
512
- # because all topics are published as *retained* by the PTR-server.
513
- # However, a short timeout is respected before raising a `KeyError`:
514
- try:
515
- return _lut[parID]
516
- except KeyError as exc:
532
+ if not parID in _lut:
533
+ # Note: The values should need NO! time to be populated from the MQTT topics,
534
+ # because all topics are published as *retained* by the PTR-server.
535
+ # However, a short timeout is respected before raising a `KeyError`:
517
536
  time.sleep(200e-3)
518
537
  rv = _lut.get(parID)
519
538
  if rv is not None:
@@ -525,6 +544,7 @@ class MqttClient(MqttClientBase):
525
544
  "set" if parID in self.set_values else
526
545
  "")
527
546
  raise KeyError(str(parID) + (' (did you mean `kind="%s"`?)' % error_hint) if error_hint else "")
547
+ return _lut[parID]
528
548
 
529
549
  def get_table(self, table_name):
530
550
  timeout_s = 10
pytrms/clients/ssevent.py CHANGED
@@ -1,48 +1,19 @@
1
1
  import re
2
- import logging
2
+ from collections import namedtuple
3
3
  from collections.abc import Iterable
4
4
 
5
5
  import requests
6
6
 
7
- from . import ionitof_url
7
+ from . import _logging
8
8
 
9
- log = logging.getLogger()
9
+ log = _logging.getLogger()
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 line_stream(response):
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):
@@ -51,32 +22,56 @@ class SSEventListener(Iterable):
51
22
  yield line
52
23
  line = ''
53
24
 
54
- def items(self):
55
- for msg in self:
56
- yield self.event, msg
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._response is None:
57
+ if self._connect_response is None:
60
58
  raise Exception("call .subscribe() first to listen for events")
61
59
 
62
- for line in self.line_stream(self._response): # blocks...
60
+ event = msg = ''
61
+ for line in self._line_stream(self._connect_response): # blocks...
63
62
  if not line.strip():
63
+ if event and any(re.match(sub, event) for sub in self.subscriptions):
64
+ yield _event_rv(event, msg)
65
+ event = msg = ''
66
+ else:
67
+ log.log(_logging.TRACE, "sse: still alive...")
64
68
  continue
65
69
 
66
- key, msg = line.split(':', maxsplit=1)
67
- msg = msg.strip()
70
+ key, val = line.split(':', maxsplit=1)
68
71
  if key == 'event':
69
- self.event = msg
70
-
71
- if not any(sub.match(self.event) for sub in self.subscriptions):
72
- log.debug(f"skipping event <{msg}>")
73
- continue
74
-
72
+ event = val.strip()
75
73
  elif key == 'data':
76
- yield msg
77
-
74
+ msg += val.strip()
78
75
  else:
79
- log.warning(f"skipping unknown key <{key}> in stream")
80
- continue
81
-
76
+ log.warning(f"unknown SSE-key <{key}> in stream")
82
77
 
@@ -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.keys():
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
- [(8, {'AME_ActionNumber': 7}), (8, {'Eins': 1}), (18, {'Zwei': 2})]
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
- >>> list(co.sequence())
88
- [(8, {'AME_ActionNumber': 7}), (8, {'Eins': 1, 'AME_StepNumber': 1, 'AME_RunNumber': 1}), (8, {'AUTO_UseMean': 0}), (10, {'AUTO_UseMean': 1}), (18, {'Zwei': 2, 'AME_StepNumber': 2}), (18, {'AUTO_UseMean': 0}), (21, {'AUTO_UseMean': 1})]
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, current in self:
138
- automation = {self.STEP_MARKER: step}
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
- set_values = dict(**set_values, **automation)
145
-
146
- yield future_cycle, set_values
147
-
148
- # insert two updates for AUTO_UseMean flag:
149
- if self.generate_automation and current.start_delay > 0:
150
- yield future_cycle, {self.USE_MARKER: 0}
151
- yield future_cycle + current.start_delay, {self.USE_MARKER: 1}
152
-
153
- future_cycle = future_cycle + current.duration
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
- import requests.exceptions
1
+ """@file helpers.py
2
2
 
3
- class PTRConnectionError(requests.exceptions.ConnectionError):
4
- pass
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/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 [H3O+] @ 21.0219>
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)) # + self.label)
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 [%s <~ %s] @ %.4f+%.4f>' % (self.__class__.__name__,
154
- self.label, self.parent, self.center, self.shift)
155
+ return '<%s @ %.4f+%.4f [%s]>' % (self.__class__.__name__,
156
+ self.center, self.shift, self.label)
155
157
 
156
158
 
157
159
  class PeakTable:
@@ -7,19 +7,10 @@ import numpy as np
7
7
  import pandas as pd
8
8
 
9
9
  from .._base import itype
10
+ from ..helpers import convert_labview_to_posix
10
11
 
11
12
  __all__ = ['IoniTOFReader', 'GroupNotFoundError']
12
13
 
13
- def convert_labview_to_posix(lv_time_utc, utc_offset_sec):
14
- '''Create a `pandas.Timestamp` from LabView time.'''
15
- # change epoch from 01.01.1904 to 01.01.1970:
16
- posix_time = lv_time_utc - 2082844800
17
- # the tz must be specified in isoformat like '+02:30'..
18
- tz_sec = int(utc_offset_sec)
19
- tz_designator = '{0}{1:02d}:{2:02d}'.format(
20
- '+' if tz_sec >= 0 else '-', tz_sec // 3600, tz_sec % 3600 // 60)
21
-
22
- return pd.Timestamp(posix_time, unit='s', tz=tz_designator)
23
14
 
24
15
  class GroupNotFoundError(KeyError):
25
16
  pass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pytrms
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Python bundle for proton-transfer reaction mass-spectrometry (PTR-MS).
5
5
  License: GPL-2.0
6
6
  Author: Moritz Koenemann
@@ -15,5 +15,5 @@ Requires-Dist: h5py (>=3.12.1,<4.0.0)
15
15
  Requires-Dist: matplotlib (>=3.9.2,<4.0.0)
16
16
  Requires-Dist: paho-mqtt (>=1.6.1,<1.7.0)
17
17
  Requires-Dist: pandas (>=2.2.3,<3.0.0)
18
- Requires-Dist: pyModbusTCP (>=0.3.0,<0.4.0)
18
+ Requires-Dist: pyModbusTCP (>=0.1.9)
19
19
  Requires-Dist: requests (>=2.32.3,<3.0.0)
@@ -0,0 +1,27 @@
1
+ pytrms/__init__.py,sha256=bEgQmBOD5ftYjdwWCsZALJzhodK8CsOxI24V8Dm9Vcg,925
2
+ pytrms/_base/__init__.py,sha256=GBALqAy1kUPMc0CWnWRmn_Cg_HGKGCeE-V2rdZEmN8A,836
3
+ pytrms/_base/ioniclient.py,sha256=f4xWW3PL0p1gP7pczBaDEva3WUUb_J73n7yTIY5pW4s,826
4
+ pytrms/_base/mqttclient.py,sha256=iua-AK7S_3rH1hsuLepqIjf7ivF5Ihb6T9OJNafJXXE,4233
5
+ pytrms/_version.py,sha256=fXD69jGmDocNcc-uipbNIhjWxC7EuyhPUQ6BrBvK0Wg,854
6
+ pytrms/clients/__init__.py,sha256=RejfXqq7zSKLMZbUHdxtBvLiyeshLX-JQzgthmpYeMw,1404
7
+ pytrms/clients/db_api.py,sha256=wEEhPUsdbqSH8TsYRICeYxAfBzdyNekM-rYe4xlCXkM,7248
8
+ pytrms/clients/ioniclient.py,sha256=pHGzaNtKuVrjZ2O2nTmZYJObNMDkxNwLcNerlFit1vA,2477
9
+ pytrms/clients/modbus.py,sha256=BbluHm8iYQY4V2Wbxd4eSiWJtIORyxRWRjhJo1_1JzQ,20585
10
+ pytrms/clients/mqtt.py,sha256=H8uRBGcB4j4MaIlN7Hk_DcRnINJ7hxSxSA6sw4YJKAM,31041
11
+ pytrms/clients/ssevent.py,sha256=umNeWMEYS65qLjmD9zawHTqzatnMDphFDALaxOgCKao,2731
12
+ pytrms/compose/__init__.py,sha256=gRkwGezTsuiMLA4jr5hhQsY7XX_FonBeWcvDfDuMFnY,30
13
+ pytrms/compose/composition.py,sha256=hlV8g6n6HaLLLKileSh7sk8EtwPvaIQjOFXCEKLDKJ0,12161
14
+ pytrms/data/IoniTofPrefs.ini,sha256=e1nU7Hyuh0efpgfN-G5-ERAFC6ZeehUiotsM4VtZIT0,1999
15
+ pytrms/data/ParaIDs.csv,sha256=eWQxmHFfeTSxFfMcFpqloiZWDyK4VLZaV9zCnzLHNYs,27996
16
+ pytrms/helpers.py,sha256=QfJgdQ6b0RcyiAuNHbDGGfuRfeZIs20dPF5flZVR12o,4979
17
+ pytrms/instrument.py,sha256=OIFTbS6fuhos6oYMsrA_qdgvs_7T-H-sOSl1a0qpxZ8,3977
18
+ pytrms/measurement.py,sha256=RqACqedT0JQDkv5cmKxcXVSgl1n0Fbp1mQj81aOsZkg,5507
19
+ pytrms/peaktable.py,sha256=Ms0_dyHg8e6Oc8mwSTk8rD70EPjosX92pDXstf4f6Tk,17572
20
+ pytrms/plotting/__init__.py,sha256=vp5mAa3npo4kP5wvXXNDxKnryFK693P4PSwC-BxEUH8,66
21
+ pytrms/plotting/plotting.py,sha256=t7RXUhjOwXchtYXVgb0rJPD_9aVMDCT6DCAGu8TZQfE,702
22
+ pytrms/readers/__init__.py,sha256=F1ZX4Jv7NcY8nuSWnIbwjYnNFC2JsTnEp0BnfBHUMSM,76
23
+ pytrms/readers/ionitof_reader.py,sha256=zWKIdkeueRMDMxknUGOu68kUrPJz0rjVu4D_aoZUQQ4,18128
24
+ pytrms-0.9.1.dist-info/LICENSE,sha256=GJsa-V1mEVHgVM6hDJGz11Tk3k0_7PsHTB-ylHb3Fns,18431
25
+ pytrms-0.9.1.dist-info/METADATA,sha256=hmNvBCwFj49gPdStWhw5DrWneupksW5DQuPsX64on7Q,763
26
+ pytrms-0.9.1.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
27
+ pytrms-0.9.1.dist-info/RECORD,,
@@ -1,169 +0,0 @@
1
- import os
2
- import json
3
- from contextlib import contextmanager
4
- import logging
5
-
6
- import requests
7
-
8
- from . import ionitof_url
9
-
10
- log = logging.getLogger()
11
-
12
-
13
- class Template:
14
- '''A template for uploading a collection item.
15
-
16
- >>> t = Template()
17
- >>> t.template == Template.default_template
18
- True
19
-
20
- >>> t.render('AME_FooNumber', 42)
21
- '{"template": {"data": [{"name": "ParaID", "value": "AME_FooNumber", "prompt": "the
22
- parameter ID"}, {"name": "ValAsString", "value": "42", "prompt": "the new value"}]}}'
23
-
24
- '''
25
-
26
- default_template = {
27
- "data": [
28
- {"name": "ParaID", "value": "AME_RunNumber", "prompt": "the parameter ID"},
29
- {"name": "ValAsString", "value": "5.000000", "prompt": "the new value"},
30
- {"name": "DataType", "value": "", "prompt": "datatype (int, float, string)"},
31
- ]
32
- }
33
-
34
- @staticmethod
35
- def download(url=ionitof_url, endpoint='/api/schedule'):
36
- r = requests.get(url + endpoint, headers={'accept': 'application/vnd.collection+json'})
37
- r.raise_for_status()
38
- j = r.json()
39
-
40
- return Template(j["collection"]["template"])
41
-
42
- def __init__(self, template=None):
43
- if template is None:
44
- template = Template.default_template
45
- self.template = dict(template)
46
-
47
- self._inserts = dict() # cache data-items sorted by semantic meaning..
48
- for insert in self.template["data"]:
49
- if 'ID' in insert["name"] or 'parameter' in insert["prompt"]:
50
- self._inserts["parID"] = insert
51
- if 'set' in insert["name"].lower() or 'value' in insert["prompt"]:
52
- self._inserts["value"] = insert
53
- if 'typ' in insert["name"].lower() or 'datatype' in insert["prompt"]:
54
- self._inserts["dtype"] = insert
55
-
56
- assert len(self._inserts) == 3, "missing or unknown name in template"
57
-
58
- def render(self, parID, value):
59
- """Prepare a request for uploading."""
60
- dtype = 'float'
61
- if isinstance(value, int): dtype = 'int'
62
- if isinstance(value, str): dtype = 'string'
63
-
64
- parID_insert = dict(self._inserts["parID"])
65
- value_insert = dict(self._inserts["value"])
66
- dtype_insert = dict(self._inserts["dtype"])
67
-
68
- parID_insert.update(value=str(parID)),
69
- value_insert.update(value=str(value)),
70
- dtype_insert.update(value=str(dtype)),
71
-
72
- return json.dumps({
73
- "template": dict(data=[
74
- parID_insert,
75
- value_insert,
76
- dtype_insert,
77
- ])}
78
- )
79
-
80
- def render_many(self, new_values):
81
- for key, value in new_values.items():
82
- yield self.render(key, value)
83
-
84
-
85
- class Dirigent:
86
-
87
- def __init__(self, url=ionitof_url, template=None):
88
- if template is None:
89
- template = Template.download(url)
90
-
91
- self.url = url
92
- self.template = template
93
- self._session = None # TODO :: ?
94
-
95
- def push(self, parID, new_value, future_cycle):
96
- uri = self.url + '/api/schedule/' + str(int(future_cycle))
97
- payload = self.template.render(parID, new_value)
98
- r = self._make_request('PUT', uri, payload=payload)
99
-
100
- return r.status_code
101
-
102
- def push_filename(self, path, future_cycle):
103
- return self.push('ACQ_SRV_SetFullStorageFile', path.replace('/', '\\'), future_cycle - 2)
104
-
105
- def find_scheduled(self, parID):
106
- uri = self.url + '/api/schedule/search'
107
- r = self._make_request('GET', uri, params={'name': str(parID)})
108
- j = r.json()
109
-
110
- return [item['href'].split('/')[-1] for item in j['collection']['items']]
111
-
112
- def _make_request(self, method, uri, params=None, payload=None):
113
- if self._session is None:
114
- session = requests # not using a session..
115
- else:
116
- session = self._session
117
-
118
- try:
119
- r = session.request(method, uri, params=params, data=payload, headers={
120
- 'content-type': 'application/vnd.collection+json'
121
- })
122
- except requests.exceptions.ConnectionError as exc:
123
- # Note: the LabVIEW-webservice seems to implement a weird HTTP:
124
- # we may get a [85] Custom Error (Bad status line) from urllib3
125
- # even though we just mis-spelled a parameter-ID ?!
126
- log.error(exc)
127
- raise KeyError(str(params, payload))
128
-
129
- log.debug(f"request to <{uri}> returned [{r.status_code}]")
130
- if not r.ok and payload:
131
- log.error(payload)
132
- r.raise_for_status()
133
-
134
- return r
135
-
136
- def wait_until(self, future_cycle):
137
- if self._session is None:
138
- session = requests # not using a session..
139
- else:
140
- session = self._session
141
-
142
- r = session.get(self.url + '/api/timing/' + str(int(future_cycle)))
143
- # ...
144
- if r.status_code == 410:
145
- # 410 Client Error: Gone
146
- log.warning("we're late, better return immediately!")
147
- return r.status_code
148
-
149
- r.raise_for_status()
150
- log.debug(f"waited until {r.json()['TimeCycle']}")
151
-
152
- return r.status_code
153
-
154
- @contextmanager
155
- def open_session(self):
156
- '''open a session for faster upload (to be used as a contextmanager).'''
157
- if self._session is None:
158
- self._session = requests.Session()
159
- try:
160
- yield self
161
- finally:
162
- self._session.close()
163
- self._session = None
164
-
165
-
166
- if __name__ == '__main__':
167
- import doctest
168
- doctest.testmod(verbose=True, optionflags=doctest.ELLIPSIS)
169
-
pytrms/tracebuffer.py DELETED
@@ -1,108 +0,0 @@
1
- import time
2
- import json
3
- import queue
4
- from itertools import chain
5
- from enum import Enum
6
- from threading import Thread, Condition
7
-
8
- import pandas as pd
9
-
10
- from .helpers import convert_labview_to_posix, PTRConnectionError
11
-
12
-
13
- def parse(response, trace='raw'):
14
- jsonized = json.loads(response)
15
- info = jsonized['TimeCycle']
16
- ts = convert_labview_to_posix(info['AbsTime'])
17
-
18
- data = [list(info.values())] + [a['Data'] for a in jsonized['AddData']] + [jsonized[trace]]
19
- desc = [list(info.keys())] + [a['Desc'] for a in jsonized['AddData']] + [jsonized['masses']]
20
- chained_data = chain(*data)
21
- chained_desc = chain(*desc)
22
-
23
- return pd.Series(data=chained_data, index=chained_desc, name=ts)
24
-
25
-
26
- class TraceBuffer(Thread):
27
-
28
- poll = 0.2 # seconds
29
-
30
- class State(Enum):
31
- CONNECTING = -1
32
- IDLE = 0
33
- ACTIVE = 1
34
-
35
- def __init__(self, client):
36
- """'client' must provide a `.get_traces()` method that returns raw json data.
37
- """
38
- Thread.__init__(self)
39
- self.daemon = True
40
- self.client = client
41
- self.queue = queue.Queue()
42
- self.state = TraceBuffer.State.CONNECTING
43
- self._cond = Condition()
44
-
45
- def is_connected(self):
46
- return self.state != TraceBuffer.State.CONNECTING
47
-
48
- def is_idle(self):
49
- return self.state == TraceBuffer.State.IDLE
50
-
51
- def wait_for_connection(self, timeout=5):
52
- '''
53
- will raise a PTRConnectionError if not connected after `timeout`
54
- '''
55
- waited = 0
56
- dt = 0.01
57
- while not self.is_connected():
58
- waited += dt
59
- if waited > timeout:
60
- raise PTRConnectionError('no connection to instrument')
61
- time.sleep(dt)
62
-
63
- def run(self):
64
- last = -753 # the year Rome was founded is never a valid cycle
65
- while True:
66
- #TODO :: das kann ja gar nicht funktionieren!?!?
67
- #if not self.is_connected():
68
- # time.sleep(self.poll)
69
- # continue
70
-
71
- time.sleep(self.poll)
72
-
73
- with self._cond: # .acquire()`s the underlying lock
74
- raw = self.client.get_traces()
75
- #try:
76
- #except PTRConnectionError as exc:
77
- # print(exc)
78
- # break
79
- if not len(raw):
80
- continue
81
-
82
- jsonized = json.loads(raw)
83
- ts = jsonized['TimeCycle']['AbsTime']
84
- oc = jsonized['TimeCycle']['OverallCycle']
85
- # the client returns the "current", i.e. last known trace data, even if
86
- # the machine is currently stopped. we want to definitely reflect this
87
- # idle state of the (actual) machine in our Python objects!
88
- # TODO :: *ideally*, the state is returned by a webAPI-call.. but as long
89
- # as this doesn't work perfectly, let's just do the next best thing and
90
- # watch the current cycle:
91
- if last < 0: last = oc
92
-
93
- if oc > last:
94
- pd_series = parse(raw)
95
- self.queue.put(pd_series)
96
- self.state = TraceBuffer.State.ACTIVE
97
- else:
98
- self.state = TraceBuffer.State.IDLE
99
- last = oc
100
-
101
- # This method releases the underlying lock, and then blocks until it is
102
- # awakened by a notify() or notify_all() call for the same condition variable
103
- # in another thread, or until the optional timeout occurs. Once awakened or
104
- # timed out, it re-acquires the lock and returns. The return value is True
105
- # unless a given timeout expired, in which case it is False.
106
- if self._cond.wait(self.poll):
107
- break
108
-
@@ -1,29 +0,0 @@
1
- pytrms/__init__.py,sha256=U4ixhTjfFkiVlLXV4JXcRq-eLFkdKlGlU4e52Zlbpsg,970
2
- pytrms/_base/__init__.py,sha256=GBALqAy1kUPMc0CWnWRmn_Cg_HGKGCeE-V2rdZEmN8A,836
3
- pytrms/_base/ioniclient.py,sha256=f4xWW3PL0p1gP7pczBaDEva3WUUb_J73n7yTIY5pW4s,826
4
- pytrms/_base/mqttclient.py,sha256=iua-AK7S_3rH1hsuLepqIjf7ivF5Ihb6T9OJNafJXXE,4233
5
- pytrms/_version.py,sha256=fXD69jGmDocNcc-uipbNIhjWxC7EuyhPUQ6BrBvK0Wg,854
6
- pytrms/clients/__init__.py,sha256=RejfXqq7zSKLMZbUHdxtBvLiyeshLX-JQzgthmpYeMw,1404
7
- pytrms/clients/db_api.py,sha256=exgE4iUHdACprztYYs330NzYI88f6TwF8PjtBzohgjk,6658
8
- pytrms/clients/dirigent.py,sha256=9WZPfqXxLmFCcT2RC8wxm7uixe4n-Y8vbsdpzc61tsI,5742
9
- pytrms/clients/ioniclient.py,sha256=5s3GPDr6AKH4Ia0Jix6UI8Xie_t3muKxr218MNRk2Yw,2853
10
- pytrms/clients/modbus.py,sha256=E1IfoHJm2gVHo-GcKDWba359ii5e0Ymaplv-DO88FqM,19223
11
- pytrms/clients/mqtt.py,sha256=QRJgC10GWFKzT3r4yNGOafXshgHlA5poGEzouEporEU,30231
12
- pytrms/clients/ssevent.py,sha256=OziYX9amJunVzTN0MiQmLg2mICHdnVC0LXT5o_ZcwcE,2546
13
- pytrms/compose/__init__.py,sha256=gRkwGezTsuiMLA4jr5hhQsY7XX_FonBeWcvDfDuMFnY,30
14
- pytrms/compose/composition.py,sha256=c3ae3PgQg0b741RhRU6oQMAiM7B2xRvbweGp0tCZatc,7906
15
- pytrms/data/IoniTofPrefs.ini,sha256=e1nU7Hyuh0efpgfN-G5-ERAFC6ZeehUiotsM4VtZIT0,1999
16
- pytrms/data/ParaIDs.csv,sha256=eWQxmHFfeTSxFfMcFpqloiZWDyK4VLZaV9zCnzLHNYs,27996
17
- pytrms/helpers.py,sha256=pNLYWlHM1n7vOkCi2vwESMG5baIJ5v2OD4z94XpXU1k,108
18
- pytrms/instrument.py,sha256=OIFTbS6fuhos6oYMsrA_qdgvs_7T-H-sOSl1a0qpxZ8,3977
19
- pytrms/measurement.py,sha256=RqACqedT0JQDkv5cmKxcXVSgl1n0Fbp1mQj81aOsZkg,5507
20
- pytrms/peaktable.py,sha256=b3KkILn5DctEfTi1-tOC9LZv1yrRcoH23VaE5JEqyk4,17536
21
- pytrms/plotting/__init__.py,sha256=vp5mAa3npo4kP5wvXXNDxKnryFK693P4PSwC-BxEUH8,66
22
- pytrms/plotting/plotting.py,sha256=t7RXUhjOwXchtYXVgb0rJPD_9aVMDCT6DCAGu8TZQfE,702
23
- pytrms/readers/__init__.py,sha256=F1ZX4Jv7NcY8nuSWnIbwjYnNFC2JsTnEp0BnfBHUMSM,76
24
- pytrms/readers/ionitof_reader.py,sha256=vhwVfhJ-ly8I_02okpgxoVqnB0QRDuqkufGZY21vFi4,18583
25
- pytrms/tracebuffer.py,sha256=30HfMJtxZ4Dnf4mPeyinXqESOA-0Xpu9FR4jaBvb_nA,3912
26
- pytrms-0.9.0.dist-info/LICENSE,sha256=GJsa-V1mEVHgVM6hDJGz11Tk3k0_7PsHTB-ylHb3Fns,18431
27
- pytrms-0.9.0.dist-info/METADATA,sha256=sTHS7ZvxCdgcaTj_hCI_Vb8erA1nIUBCs4JMGb06mRg,770
28
- pytrms-0.9.0.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
29
- pytrms-0.9.0.dist-info/RECORD,,
File without changes