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