pytrms 0.2.1__py3-none-any.whl → 0.9.0__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.
@@ -0,0 +1,495 @@
1
+ """Module modbus.py
2
+
3
+ """
4
+ import os
5
+ import struct
6
+ import time
7
+ import logging
8
+ from collections import namedtuple
9
+ from functools import lru_cache
10
+ from itertools import tee
11
+
12
+ from pyModbusTCP import client
13
+
14
+ from . import _par_id_file
15
+ from .._base.ioniclient import IoniClientBase
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+ __all__ = ['IoniconModbus']
20
+
21
+
22
+ with open(_par_id_file) as f:
23
+ it = iter(f)
24
+ assert next(it).startswith('ID\tName'), "Modbus parameter file is corrupt: " + f.name
25
+ _id_to_descr = {int(id_): name for id_, name, *_ in (line.strip().split('\t') for line in it)}
26
+
27
+ # look-up-table for c_structs (see docstring of struct-module for more info).
28
+ # Note: almost *all* parameters used by IoniTOF (esp. AME) are 'float', with
29
+ # some exceptions that are 'short' (alive_counter, n_parameters) or explicitly
30
+ # marked to be 'int' (AME_RunNumber, et.c.):
31
+ _fmts = dict([
32
+ ('float', '>f'),
33
+ ('double', '>d'),
34
+ ('short', '>h'),
35
+ ('int', '>i'),
36
+ ('long', '>q'),
37
+ ])
38
+
39
+ _register = namedtuple('register_info', ['n_registers', 'c_format', 'reg_format'])
40
+ _timecycle = namedtuple('timecycle_info', ('rel_cycle', 'abs_cycle', 'abs_time', 'rel_time'))
41
+ _parameter = namedtuple('parameter_info', ('set', 'act', 'par_id', 'state'))
42
+
43
+
44
+ def _get_fmt(c_format):
45
+ if c_format in _fmts:
46
+ c_format = _fmts[c_format]
47
+
48
+ n_registers = max(struct.calcsize(c_format) // 2, 1)
49
+ reg_format = '>' + 'H' * n_registers
50
+
51
+ return _register(n_registers, c_format, reg_format)
52
+
53
+
54
+ def _unpack(registers, format='>f'):
55
+ """Convert a list of register values to a numeric Python value.
56
+
57
+ Depending on 'c_type', the value is packed into two or four
58
+ 8-bit registers, for 2-byte (single) and 4-byte (double)
59
+ representation, respectively.
60
+
61
+ >>> _unpack([17448, 0], 'float')
62
+ 672.
63
+
64
+ >>> _unpack([17446, 32768], 'float')
65
+ 666.
66
+
67
+ >>> _unpack([16875, 61191, 54426, 37896], 'double')
68
+ 3749199524.83057
69
+
70
+ >>> _unpack([16875, 61191, 54426, 37896], 'long')
71
+ 4750153048903029768
72
+
73
+ """
74
+ n, c_format, reg_format = _get_fmt(format)
75
+ assert n == len(registers), f"c_format '{c_format}' needs [{n}] registers (got [{len(registers)}])"
76
+
77
+ return struct.unpack(c_format, struct.pack(reg_format, *registers))[0]
78
+
79
+ def _pack(value, format='>f'):
80
+ """Convert floating point 'value' to registers.
81
+
82
+ Depending on 'c_type', the value is packed into two or four
83
+ 8-bit registers, for 2-byte (single) and 4-byte (double)
84
+ representation, respectively.
85
+ """
86
+ _, c_format, reg_format = _get_fmt(format)
87
+
88
+ return struct.unpack(reg_format, struct.pack(c_format, value))
89
+
90
+
91
+ class IoniconModbus(IoniClientBase):
92
+
93
+ address = dict([
94
+ ('server_state', ( 0, '>f', True)), # 0: Not ready, 1: Ready, 2: Startup
95
+ ('measure_state', ( 2, '>f', True)), # 0: Not running | 1: running | 2: Just Started | 3: Just Stopped
96
+ ('instrument_state', ( 4, '>f', True)), # 0: Not Ok, 1: Ok, 2: Error, 3: Warning
97
+ ('alive_counter', ( 6, '>H', True)), # (updated every 500 ms)
98
+ ('n_parameters', ( 2000, '>H', True)),
99
+ ('tc_raw', ( 4000, '>f', True)),
100
+ ('tc_conc', ( 6000, '>f', True)),
101
+ ('n_masses', ( 8000, '>f', True)),
102
+ # ('n_corr', ( 7000, '>i', True)), # not implemented?
103
+ ('tc_components', (10000, '>f', True)),
104
+ ('ame_alarms', (12000, '>f', True)),
105
+ ('user_number', (13900, '>i', True)),
106
+ ('step_number', (13902, '>i', True)),
107
+ ('run_number', (13904, '>i', True)),
108
+ ('use_mean', (13906, '>i', True)),
109
+ ('action_number', (13912, '>i', True)),
110
+ ('version_major', (13918, '>h', True)),
111
+ ('version_minor', (13919, '>h', True)),
112
+ ('version_patch', (13920, '>h', True)),
113
+ ('ame_state', (13914, '>i', True)), # Running 0=Off; 1=On (not implemented!)
114
+ ('n_components', (14000, '>f', True)),
115
+ ('component_names', (14002, '>f', True)),
116
+ ('ame_mean_data', (26000, '>f', True)),
117
+ ('n_ame_mean', (26002, '>d', True)),
118
+ ])
119
+
120
+ @classmethod
121
+ def use_legacy_input_registers(klaas, use_input_reg = True):
122
+ """Read from input- instead of holding-registers (legacy method to be compatible with AME1.0)."""
123
+ use_holding = not use_input_reg
124
+ modded = dict()
125
+ for key, vals in klaas.address.items():
126
+ modded[key] = vals[0], vals[1], use_holding
127
+ klaas.address.update(modded)
128
+
129
+ @property
130
+ def _alive_counter(self):
131
+ return self._read_reg(*self.address['alive_counter'])
132
+
133
+ @property
134
+ def is_connected(self):
135
+ if not self.mc.is_open:
136
+ return False
137
+
138
+ # wait for the IoniTOF alive-counter to change (1 second max)...
139
+ initial_count = self._alive_counter
140
+ timeout_s = 3 # counter should increase every 500 ms, approximately
141
+ started_at = time.monotonic()
142
+ while time.monotonic() < started_at + timeout_s:
143
+ if initial_count != self._alive_counter:
144
+ return True
145
+
146
+ time.sleep(10e-3)
147
+ return False
148
+
149
+ @property
150
+ def is_running(self):
151
+ return 1.0 == self._read_reg(*self.address['measure_state'])
152
+
153
+ @property
154
+ def error_state(self):
155
+ value = self._read_reg(*self.address['instrument_state'])
156
+ return IoniconModbus._instrument_states.get(value, value)
157
+
158
+ _instrument_states = {
159
+ 0.0: "Not Okay",
160
+ 1.0: "Okay",
161
+ 2.0: "Error",
162
+ 3.0: "Warning",
163
+ }
164
+
165
+ @property
166
+ def server_state(self):
167
+ value = self._read_reg(*self.address['server_state'])
168
+ return IoniconModbus._server_states.get(value, value)
169
+
170
+ _server_states = {
171
+ 0.0: "Not Ready",
172
+ 1.0: "Ready",
173
+ 2.0: "Startup",
174
+ }
175
+
176
+ def __init__(self, host='localhost', port=502):
177
+ super().__init__(host, port)
178
+ self.mc = client.ModbusClient(host=self.host, port=self.port)
179
+ # try connect immediately:
180
+ try:
181
+ self.connect()
182
+ except TimeoutError as exc:
183
+ log.warn(f"{exc} (retry connecting when the Instrument is set up)")
184
+ self._addresses = {}
185
+
186
+ def connect(self, timeout_s=10):
187
+ log.info(f"[{self}] connecting to Modbus server...")
188
+ self.mc.timeout = timeout_s
189
+ self.mc.auto_open = True
190
+ if not self.mc.open():
191
+ raise TimeoutError(f"[{self}] no connection to modbus socket")
192
+
193
+ started_at = time.monotonic()
194
+ while time.monotonic() < started_at + timeout_s:
195
+ if self.is_connected:
196
+ break
197
+
198
+ time.sleep(10e-3)
199
+ else:
200
+ self.disconnect()
201
+ raise TimeoutError(f"[{self}] no connection to IoniTOF");
202
+
203
+ def disconnect(self):
204
+ if self.mc.is_open:
205
+ self.mc.close()
206
+
207
+ @property
208
+ @lru_cache
209
+ def n_parameters(self):
210
+ return int(self._read_reg(*self.address['n_parameters']))
211
+
212
+ @property
213
+ @lru_cache
214
+ def n_masses(self):
215
+ return int(self._read_reg(*self.address['n_masses']))
216
+
217
+ @property
218
+ @lru_cache
219
+ def n_components(self):
220
+ return int(self._read_reg(*self.address['n_components']))
221
+
222
+ def read_parameter(self, par_name):
223
+ """Read any previously loaded parameter by name.
224
+
225
+ For example, after calling `.read_components()`, one can call
226
+ `.read_parameter('DPS_Udrift')` to get only this value with no overhead.
227
+ """
228
+ try:
229
+ return self._read_reg(*self.address[par_name])
230
+ except KeyError as exc:
231
+ raise KeyError("did you call one of .read_instrument_data(), .read_traces(), et.c. first?")
232
+
233
+ def read_instrument_data(self):
234
+
235
+ # Each block consists of 6 registers:
236
+ # Register 1: Parameter ID
237
+ # Register 2-3: Parameter Set Value as float(real)
238
+ # Register 4-5: Parameter Act Value as float(real)
239
+ # Register 6: Parameter state
240
+ start_register = 2001
241
+ blocksize = 6
242
+ superblocksize = 20*blocksize
243
+
244
+ rv = dict()
245
+ # read 20 parameters at once to save transmission..
246
+ for superblock in range(0, blocksize*self.n_parameters, superblocksize):
247
+ offset = start_register + superblock
248
+ # always using input_register
249
+ input_regs = self.mc.read_input_registers(offset, superblocksize)
250
+ # ..and handle one block per parameter:
251
+ for block in range(0, superblocksize, blocksize):
252
+ par_id, set1, set2, act1, act2, state = input_regs[block:block+6]
253
+ par_id = _unpack([par_id], '>H')
254
+ if len(rv) >= self.n_parameters or par_id == 0:
255
+ break
256
+
257
+ try:
258
+ descr = _id_to_descr[par_id]
259
+ except KeyError as exc:
260
+ log.error("par Id %d not in par_ID_list!" % (par_id))
261
+ continue
262
+
263
+ # save the *act-value* in the address-space for faster lookup:
264
+ self.address[descr] = (start_register + superblock + block + 1 + 2, '>f')
265
+ vset = _unpack([set1, set2], '>f')
266
+ vact = _unpack([act1, act2], '>f')
267
+ rv[descr] = _parameter(vset, vact, par_id, state)
268
+
269
+ return rv
270
+
271
+ def write_instrument_data(self, par_id, new_value, timeout_s=10):
272
+
273
+ # Each command-block consists of 3 registers:
274
+ # Register 1: Parameter ID
275
+ # Register 2-3: Parameter Set Value as float(real)
276
+ start_register = 40000
277
+ blocksize = 3
278
+
279
+ if isinstance(par_id, str):
280
+ try:
281
+ par_id = next(k for k, v in _id_to_descr.items() if v == par_id)
282
+ except StopIteration as exc:
283
+ raise KeyError(str(par_id))
284
+ par_id = int(par_id)
285
+ if par_id not in _id_to_descr:
286
+ raise IndexError(str(par_id))
287
+
288
+ start_register += blocksize * par_id
289
+
290
+ retry_time = 0
291
+ while retry_time < timeout_s:
292
+ # a value of 0 indicates ready-to-write:
293
+ if self.mc.read_holding_registers(start_register) == [0]:
294
+ break
295
+ retry_time += 0.5
296
+ time.sleep(0.5)
297
+ else:
298
+ raise TimeoutError(f'register {start_register} timed out after {timeout_s}s')
299
+
300
+ reg_values = [start_register] + list(_pack(new_value))
301
+ self.mc.write_multiple_registers(start_register, reg_values)
302
+
303
+ @lru_cache
304
+ def read_masses(self, update_address_at=None, with_format='>f'):
305
+ start_reg, c_fmt, _ = self.address['n_masses']
306
+ start_reg += 2 # number of components as float..
307
+ masses = self._read_reg_multi(start_reg, c_fmt, self.n_masses) # input_register
308
+ if update_address_at:
309
+ n_bytes, c_fmt, _ = _get_fmt(with_format)
310
+ self.address.update({
311
+ "{0:.4}".format(mass): (update_address_at + i * n_bytes, c_fmt)
312
+ for i, mass in enumerate(masses)
313
+ })
314
+
315
+ return masses
316
+
317
+ def read_traces(self, kind='conc'):
318
+ """Returns the current traces, where `kind` is one of 'conc', 'raw', 'components'.
319
+ """
320
+ start_reg, c_fmt, _is_holding = self.address['tc_' + kind]
321
+ start_reg += 14 # skipping timecycles..
322
+
323
+ # update the address-space with the current kind
324
+ # for later calls to `.read_parameter()`:
325
+ masses = self.read_masses(update_address_at=start_reg)
326
+ values = self._read_reg_multi(start_reg, c_fmt, self.n_masses, _is_holding)
327
+
328
+ return dict(zip(
329
+ ("{0:.4}".format(mass) for mass in masses), values
330
+ ))
331
+
332
+ def read_timecycle(self, kind='conc'):
333
+ """Returns the current timecycle, where `kind` is one of conc|raw|components.
334
+
335
+ Absolute time as double (8 bytes), seconds since 01.01.1904, 01:00 am.
336
+ Relative time as double (8 bytes) in seconds since measurement start.
337
+ """
338
+ start_reg, _, _is_holding = self.address['tc_' + kind]
339
+
340
+ return _timecycle(
341
+ int( self._read_reg(start_reg + 2, '>f', _is_holding)),
342
+ int( self._read_reg(start_reg + 4, '>f', _is_holding)),
343
+ float(self._read_reg(start_reg + 6, '>d', _is_holding)),
344
+ float(self._read_reg(start_reg + 10, '>d', _is_holding)),
345
+ )
346
+
347
+ @lru_cache
348
+ def read_component_names(self):
349
+ # 14002 ff: Component Names (maximum length per name:
350
+ # 32 chars (=16 registers), e.g. 14018 to 14033: "Isoprene")
351
+ name_start_reg, _, _is_holding = self.address['component_names']
352
+ data_start_reg, c_fmt, _ = self.address['tc_components']
353
+ data_start_reg += 14 # skip timecycle info..
354
+
355
+ _read = self.mc.read_holding_registers if _is_holding else self.mc.read_input_registers
356
+ rv = []
357
+ for index in range(self.n_components):
358
+ n_bytes, c_format, reg_format = _get_fmt('>16H')
359
+ register = _read(name_start_reg + index * 16, n_bytes)
360
+ chars = struct.pack(reg_format, *register)
361
+ decoded = chars.decode('latin-1').strip('\x00')
362
+ self.address[decoded] = (data_start_reg + index * 2, c_fmt, _is_holding)
363
+ rv.append(decoded)
364
+
365
+ return rv
366
+
367
+ def read_components(self):
368
+ start_reg, c_fmt, _is_holding = self.address['tc_components']
369
+ start_reg += 14 # skipping timecycles...
370
+ values = self._read_reg_multi(start_reg, c_fmt, self.n_components, _is_holding)
371
+
372
+ return dict(zip(self.read_component_names(), values))
373
+
374
+ def read_ame_version(self):
375
+ start_reg, c_fmt, _is_holding = self.address['version_major']
376
+ major, minor, patch = self._read_reg_multi(start_reg, c_fmt, 3, _is_holding)
377
+
378
+ return f"{major}.{minor}.{patch}"
379
+
380
+ def read_ame_alarms(self):
381
+ start_reg, c_fmt, _is_holding = self.address['ame_alarms']
382
+ n_alarms = int(self._read_reg(start_reg, c_fmt, _is_holding))
383
+ values = self._read_reg_multi(start_reg + 2, c_fmt, n_alarms, _is_holding)
384
+
385
+ return dict(zip(self.read_component_names(), map(bool, values)))
386
+
387
+ def read_ame_timecycle(self):
388
+ return self.read_timecycle(kind='components')
389
+
390
+ _ame_parameter = namedtuple('ame_parameter', [
391
+ 'step_number',
392
+ 'run_number',
393
+ 'use_mean',
394
+ 'action_number',
395
+ 'user_number'
396
+ ])
397
+
398
+ def read_ame_numbers(self):
399
+ return IoniconModbus._ame_parameter(
400
+ int(self._read_reg(*self.address['step_number'])),
401
+ int(self._read_reg(*self.address['run_number'])),
402
+ int(self._read_reg(*self.address['use_mean'])),
403
+ int(self._read_reg(*self.address['action_number'])),
404
+ int(self._read_reg(*self.address['user_number'])),
405
+ )
406
+
407
+ _ame_mean = namedtuple('ame_mean_info', [
408
+ 'data_ok',
409
+ 'mod_time',
410
+ 'start_cycle',
411
+ 'stop_cycle',
412
+ 'mean_values',
413
+ 'std_error',
414
+ ])
415
+
416
+ def write_ame_action(self, action_number):
417
+ start_reg, c_fmt, _ = self.address['action_number']
418
+ set_value = _pack(action_number, c_fmt)
419
+ self.mc.write_multiple_registers(start_reg, set_value)
420
+
421
+ def read_ame_mean(self, step_number=None):
422
+ start_reg, c_fmt, _is_holding = self.address['ame_mean_data']
423
+ data_ok = int(self._read_reg(start_reg, c_fmt, _is_holding))
424
+ if not data_ok:
425
+ return IoniconModbus._ame_mean(data_ok, 0, 0, 0, [], [])
426
+
427
+ n_masses = int(self._read_reg(start_reg + 6, c_fmt, _is_holding))
428
+ n_steps = int(self._read_reg(start_reg + 8, c_fmt, _is_holding))
429
+
430
+ if step_number is None:
431
+ step_number = int(self._read_reg(*self.address['step_number']))
432
+ elif not (0 < step_number <= n_steps):
433
+ raise IndexError(f"step_number [{step_number}] out of bounds: 0 < step <= [{n_steps}]")
434
+
435
+ datablock_size = 1 + 1 + n_masses + n_masses
436
+ start_addr = (start_reg
437
+ + 10
438
+ + n_masses * 2 # skip the masses, same as everywhere
439
+ + (step_number-1) * datablock_size * 2) # select datablock
440
+
441
+ return IoniconModbus._ame_mean(
442
+ data_ok,
443
+ self._read_reg(*self.address['n_ame_mean']),
444
+ self._read_reg(start_addr + 0, c_fmt, _is_holding),
445
+ self._read_reg(start_addr + 2, c_fmt, _is_holding),
446
+ self._read_reg_multi(start_addr + 4, c_fmt, n_masses, _is_holding),
447
+ self._read_reg_multi(start_addr + 4 + n_masses * 2, c_fmt, n_masses, _is_holding),
448
+ )
449
+
450
+ def _read_reg(self, addr, c_format, is_holding_register=False):
451
+ n_bytes, c_format, reg_format = _get_fmt(c_format)
452
+ _read = self.mc.read_holding_registers if is_holding_register else self.mc.read_input_registers
453
+
454
+ register = _read(addr, n_bytes)
455
+ if register is None and self.mc.is_open:
456
+ raise IOError(f"unable to read ({n_bytes}) registers at [{addr}] from connection")
457
+ elif register is None and not self.mc.is_open:
458
+ raise IOError("trying to read from closed Modbus-connection")
459
+
460
+ return _unpack(register, c_format)
461
+
462
+ def _read_reg_multi(self, addr, c_format, n_values, is_holding_register=False):
463
+ rv = []
464
+ if not n_values > 0:
465
+ return rv
466
+
467
+ _read = self.mc.read_holding_registers if is_holding_register else self.mc.read_input_registers
468
+
469
+ n_bytes, c_format, reg_format = _get_fmt(c_format)
470
+ n_regs = n_bytes * n_values
471
+
472
+ # Note: there seems to be a limitation of modbus that
473
+ # the limits the number of registers to 125, so we
474
+ # read input-registers in blocks of 120:
475
+ blocks = ((addr + block, min(120, n_regs - block))
476
+ for block in range(0, n_regs, 120))
477
+
478
+ for block in blocks:
479
+ register = _read(*block)
480
+ if register is None and self.is_connected:
481
+ raise IOError(f"unable to read ({block[1]}) registers at [{block[0]}] from connection")
482
+ elif register is None and not self.is_connected:
483
+ raise IOError("trying to read from closed Modbus-connection")
484
+
485
+ # group the register-values by n_bytes, e.g. [1,2,3,4,..] ~> [(1,2),(3,4),..]
486
+ # this is a trick from the itertools-recipes, see
487
+ # https://docs.python.org/3.8/library/itertools.html?highlight=itertools#itertools-recipes
488
+ # note, that the iterator is cloned n-times and therefore
489
+ # all clones advance in parallel and can be zipped below:
490
+ batches = [iter(register)] * n_bytes
491
+
492
+ rv += [_unpack(reg, c_format) for reg in zip(*batches)]
493
+
494
+ return rv
495
+