pytrms 0.2.2__tar.gz → 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytrms-0.9.0/PKG-INFO +19 -0
- {pytrms-0.2.2 → pytrms-0.9.0}/pyproject.toml +15 -10
- pytrms-0.9.0/pytrms/__init__.py +37 -0
- pytrms-0.9.0/pytrms/_base/__init__.py +24 -0
- pytrms-0.9.0/pytrms/_base/ioniclient.py +32 -0
- pytrms-0.9.0/pytrms/_base/mqttclient.py +106 -0
- pytrms-0.9.0/pytrms/clients/__init__.py +33 -0
- pytrms-0.9.0/pytrms/clients/db_api.py +186 -0
- pytrms-0.9.0/pytrms/clients/dirigent.py +169 -0
- pytrms-0.9.0/pytrms/clients/modbus.py +495 -0
- pytrms-0.9.0/pytrms/clients/mqtt.py +773 -0
- pytrms-0.9.0/pytrms/clients/ssevent.py +82 -0
- pytrms-0.9.0/pytrms/compose/__init__.py +2 -0
- pytrms-0.9.0/pytrms/compose/composition.py +207 -0
- pytrms-0.9.0/pytrms/data/ParaIDs.csv +731 -0
- pytrms-0.9.0/pytrms/helpers.py +6 -0
- pytrms-0.9.0/pytrms/instrument.py +114 -0
- pytrms-0.9.0/pytrms/measurement.py +171 -0
- pytrms-0.9.0/pytrms/peaktable.py +499 -0
- pytrms-0.9.0/pytrms/plotting/__init__.py +4 -0
- pytrms-0.9.0/pytrms/readers/__init__.py +4 -0
- pytrms-0.9.0/pytrms/readers/ionitof_reader.py +481 -0
- pytrms-0.2.2/PKG-INFO +0 -17
- pytrms-0.2.2/par_ID_list.txt +0 -653
- pytrms-0.2.2/pytrms/__init__.py +0 -42
- pytrms-0.2.2/pytrms/clients/__init__.py +0 -7
- pytrms-0.2.2/pytrms/clients/mockclient.py +0 -612
- pytrms-0.2.2/pytrms/factory.py +0 -35
- pytrms-0.2.2/pytrms/helpers.py +0 -17
- pytrms-0.2.2/pytrms/instrument.py +0 -178
- pytrms-0.2.2/pytrms/measurement.py +0 -90
- pytrms-0.2.2/pytrms/modbus.py +0 -126
- pytrms-0.2.2/pytrms/reader.py +0 -177
- pytrms-0.2.2/pytrms/testing/__init__.py +0 -14
- pytrms-0.2.2/pytrms/testing/response_TRACES_webAPI.txt +0 -556
- pytrms-0.2.2/setup.py +0 -33
- {pytrms-0.2.2 → pytrms-0.9.0}/LICENSE +0 -0
- {pytrms-0.2.2 → pytrms-0.9.0}/pytrms/_version.py +0 -0
- {pytrms-0.2.2 → pytrms-0.9.0}/pytrms/clients/ioniclient.py +0 -0
- {pytrms-0.2.2 → pytrms-0.9.0}/pytrms/data/IoniTofPrefs.ini +0 -0
- {pytrms-0.2.2/pytrms → pytrms-0.9.0/pytrms/plotting}/plotting.py +0 -0
- {pytrms-0.2.2 → pytrms-0.9.0}/pytrms/tracebuffer.py +0 -0
pytrms-0.9.0/PKG-INFO
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pytrms
|
|
3
|
+
Version: 0.9.0
|
|
4
|
+
Summary: Python bundle for proton-transfer reaction mass-spectrometry (PTR-MS).
|
|
5
|
+
License: GPL-2.0
|
|
6
|
+
Author: Moritz Koenemann
|
|
7
|
+
Author-email: moritz.koenemann@ionicon.com
|
|
8
|
+
Requires-Python: >=3.10,<4.0
|
|
9
|
+
Classifier: License :: OSI Approved :: GNU General Public License v2 (GPLv2)
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Dist: h5py (>=3.12.1,<4.0.0)
|
|
15
|
+
Requires-Dist: matplotlib (>=3.9.2,<4.0.0)
|
|
16
|
+
Requires-Dist: paho-mqtt (>=1.6.1,<1.7.0)
|
|
17
|
+
Requires-Dist: pandas (>=2.2.3,<3.0.0)
|
|
18
|
+
Requires-Dist: pyModbusTCP (>=0.3.0,<0.4.0)
|
|
19
|
+
Requires-Dist: requests (>=2.32.3,<3.0.0)
|
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "pytrms"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.0"
|
|
4
4
|
description = "Python bundle for proton-transfer reaction mass-spectrometry (PTR-MS)."
|
|
5
5
|
authors = ["Moritz Koenemann <moritz.koenemann@ionicon.com>"]
|
|
6
6
|
license = "GPL-2.0"
|
|
7
7
|
include = [
|
|
8
|
-
"
|
|
8
|
+
"pytrms/data/ParaIDs.csv",
|
|
9
9
|
"pytrms/data/IoniTofPrefs.ini",
|
|
10
10
|
]
|
|
11
11
|
|
|
12
12
|
[tool.poetry.dependencies]
|
|
13
|
-
python = "^3.
|
|
14
|
-
h5py = "^3.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
python = "^3.10"
|
|
14
|
+
h5py = "^3.12.1"
|
|
15
|
+
matplotlib = "^3.9.2"
|
|
16
|
+
requests = "^2.32.3"
|
|
17
|
+
pandas = "^2.2.3"
|
|
18
|
+
pyModbusTCP = "~0.3.0"
|
|
19
|
+
paho-mqtt = "~1.6.1"
|
|
20
|
+
|
|
21
|
+
[tool.poetry.group.test.dependencies]
|
|
22
|
+
pytest = "^8.3.0"
|
|
18
23
|
|
|
19
24
|
[tool.poetry.group.dev.dependencies]
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
Sphinx = "^5.3.0"
|
|
25
|
+
Sphinx = "^8.0.0"
|
|
26
|
+
ipykernel = "^6.29.0"
|
|
23
27
|
|
|
24
28
|
[build-system]
|
|
25
29
|
requires = ["poetry-core>=1.0.0"]
|
|
26
30
|
build-backend = "poetry.core.masonry.api"
|
|
31
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
_version = '0.9.0'
|
|
2
|
+
|
|
3
|
+
__all__ = ['load', 'connect']
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def load(path):
|
|
7
|
+
'''Open a datafile for post-analysis or batch processing.
|
|
8
|
+
|
|
9
|
+
returns a `Measurement`.
|
|
10
|
+
'''
|
|
11
|
+
from .measurement import OfflineMeasurement
|
|
12
|
+
from .readers import IoniTOFReader
|
|
13
|
+
|
|
14
|
+
reader = IoniTOFReader(path)
|
|
15
|
+
|
|
16
|
+
return OfflineMeasurement(reader)
|
|
17
|
+
|
|
18
|
+
def connect(host=None, method='webapi'):
|
|
19
|
+
'''Connect a client to a running measurement server.
|
|
20
|
+
|
|
21
|
+
'method' is the preferred connection, either 'webapi' (default) or 'modbus'.
|
|
22
|
+
|
|
23
|
+
returns an `Instrument` if connected successfully.
|
|
24
|
+
'''
|
|
25
|
+
from .instrument import Instrument
|
|
26
|
+
from .helpers import PTRConnectionError
|
|
27
|
+
|
|
28
|
+
if method.lower() == 'webapi':
|
|
29
|
+
from .clients.ioniclient import IoniClient
|
|
30
|
+
return IoniClient(host)
|
|
31
|
+
|
|
32
|
+
if method.lower() == 'modbus':
|
|
33
|
+
from .modbus import IoniconModbus
|
|
34
|
+
return IoniconModbus(host)
|
|
35
|
+
|
|
36
|
+
raise NotImplementedError(str(method))
|
|
37
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from collections import namedtuple
|
|
2
|
+
|
|
3
|
+
from .mqttclient import MqttClientBase
|
|
4
|
+
from .ioniclient import IoniClientBase
|
|
5
|
+
|
|
6
|
+
class itype:
|
|
7
|
+
|
|
8
|
+
table_setting_t = namedtuple('mass_mapping', ['name', 'mass2value'])
|
|
9
|
+
timecycle_t = namedtuple('timecycle', ['rel_cycle','abs_cycle','abs_time','rel_time'])
|
|
10
|
+
masscal_t = namedtuple('masscal', ['mode', 'masses', 'timebins', 'cal_pars', 'cal_segs'])
|
|
11
|
+
add_data_item_t = namedtuple('add_data', ['value', 'name', 'unit', 'view'])
|
|
12
|
+
fullcycle_t = namedtuple('fullcycle', ['timecycle', 'intensity', 'mass_cal', 'add_data'])
|
|
13
|
+
|
|
14
|
+
AME_RUN = 8
|
|
15
|
+
AME_STEP = 7
|
|
16
|
+
AME_ACTION = 5
|
|
17
|
+
USE_MEAN = 2 # (only in AUTO_UseMean)
|
|
18
|
+
|
|
19
|
+
REACT_Udrift = 0
|
|
20
|
+
REACT_pDrift = 1
|
|
21
|
+
REACT_Tdrift = 2
|
|
22
|
+
REACT_PI_Idx = 4 # skipping E/N = 3
|
|
23
|
+
REACT_TM_Idx = 5
|
|
24
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
class IoniClientBase(ABC):
|
|
4
|
+
|
|
5
|
+
@property
|
|
6
|
+
@abstractmethod
|
|
7
|
+
def is_connected(self):
|
|
8
|
+
'''Returns `True` if connection to IoniTOF could be established.'''
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def is_running(self):
|
|
14
|
+
'''Returns `True` if IoniTOF is currently acquiring data.'''
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def connect(self, timeout_s):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def disconnect(self):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def __init__(self, host, port):
|
|
26
|
+
# Note: circumvent (potentially sluggish) Windows DNS lookup:
|
|
27
|
+
self.host = '127.0.0.1' if host == 'localhost' else str(host)
|
|
28
|
+
self.port = int(port)
|
|
29
|
+
|
|
30
|
+
def __repr__(self):
|
|
31
|
+
return f"<{self.__class__.__name__} @ {self.host}[:{self.port}]>"
|
|
32
|
+
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import logging
|
|
4
|
+
import json
|
|
5
|
+
from collections import deque
|
|
6
|
+
from itertools import cycle
|
|
7
|
+
from threading import Condition, RLock
|
|
8
|
+
from datetime import datetime as dt
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
|
|
11
|
+
import paho.mqtt.client as mqtt
|
|
12
|
+
|
|
13
|
+
from .ioniclient import IoniClientBase
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
__all__ = ['MqttClientBase']
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _on_connect(client, self, flags, rc):
|
|
21
|
+
# Note: ensure subscription after re-connecting,
|
|
22
|
+
# wildcards are '+' (one level), '#' (all levels):
|
|
23
|
+
default_QoS = 2
|
|
24
|
+
topics = set()
|
|
25
|
+
for subscriber in self._subscriber_functions:
|
|
26
|
+
topics.update(set(getattr(subscriber, "topics", [])))
|
|
27
|
+
subs = sorted(zip(topics, cycle([default_QoS])))
|
|
28
|
+
log.debug(f"[{self}] " + "\n --> ".join(["subscribing to"] + list(map(str, subs))))
|
|
29
|
+
rv = client.subscribe(subs)
|
|
30
|
+
log.info(f"[{self}] successfully connected with {rv = }")
|
|
31
|
+
|
|
32
|
+
def _on_subscribe(client, self, mid, granted_qos):
|
|
33
|
+
log.info(f"[{self}] successfully subscribed with {mid = } | {granted_qos = }")
|
|
34
|
+
|
|
35
|
+
def _on_publish(client, self, mid):
|
|
36
|
+
log.debug(f"[{self}] published {mid = }")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MqttClientBase(IoniClientBase):
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def is_connected(self):
|
|
44
|
+
'''Returns `True` if connected to the server.
|
|
45
|
+
|
|
46
|
+
Note: this property will be polled on initialization and should
|
|
47
|
+
return `True` if a connection could be established!
|
|
48
|
+
'''
|
|
49
|
+
return (True
|
|
50
|
+
and self.client.is_connected())
|
|
51
|
+
|
|
52
|
+
def __init__(self, host, subscriber_functions,
|
|
53
|
+
on_connect, on_subscribe, on_publish,
|
|
54
|
+
connect_timeout_s=10):
|
|
55
|
+
super().__init__(host, port=1883)
|
|
56
|
+
# configure connection...
|
|
57
|
+
self.client = mqtt.Client(clean_session=True)
|
|
58
|
+
# clean_session is a boolean that determines the client type. If True,
|
|
59
|
+
# the broker will remove all information about this client when it
|
|
60
|
+
# disconnects. If False, the client is a persistent client and
|
|
61
|
+
# subscription information and queued messages will be retained when the
|
|
62
|
+
# client disconnects.
|
|
63
|
+
# The clean_session argument only applies to MQTT versions v3.1.1 and v3.1.
|
|
64
|
+
# It is not accepted if the MQTT version is v5.0 - use the clean_start
|
|
65
|
+
# argument on connect() instead.
|
|
66
|
+
self.client.on_connect = on_connect if on_connect is not None else _on_connect
|
|
67
|
+
self.client.on_subscribe = on_subscribe if on_subscribe is not None else _on_subscribe
|
|
68
|
+
self.client.on_publish = on_publish if on_publish is not None else _on_publish
|
|
69
|
+
# ...subscribe to topics...
|
|
70
|
+
self._subscriber_functions = list(subscriber_functions)
|
|
71
|
+
for subscriber in self._subscriber_functions:
|
|
72
|
+
for topic in getattr(subscriber, "topics", []):
|
|
73
|
+
self.client.message_callback_add(topic, subscriber)
|
|
74
|
+
# ...pass this instance to each callback...
|
|
75
|
+
self.client.user_data_set(self)
|
|
76
|
+
# ...and connect to the server:
|
|
77
|
+
try:
|
|
78
|
+
self.connect(connect_timeout_s)
|
|
79
|
+
except TimeoutError as exc:
|
|
80
|
+
log.warn(f"{exc} (retry connecting when the Instrument is set up)")
|
|
81
|
+
|
|
82
|
+
def connect(self, timeout_s=10):
|
|
83
|
+
log.info(f"[{self}] connecting to MQTT broker...")
|
|
84
|
+
self.client.connect(self.host, self.port, timeout_s)
|
|
85
|
+
self.client.loop_start() # runs in a background thread
|
|
86
|
+
started_at = time.monotonic()
|
|
87
|
+
while time.monotonic() < started_at + timeout_s:
|
|
88
|
+
if self.is_connected:
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
time.sleep(10e-3)
|
|
92
|
+
else:
|
|
93
|
+
self.disconnect()
|
|
94
|
+
raise TimeoutError(f"[{self}] no connection to IoniTOF")
|
|
95
|
+
|
|
96
|
+
def publish_with_ack(self, *args, timeout_s=10, **kwargs):
|
|
97
|
+
# Note: this is important when publishing just before exiting the application
|
|
98
|
+
# to ensure that all messages get through (timeout_s is set on `.__init__()`)
|
|
99
|
+
msg = self.client.publish(*args, **kwargs)
|
|
100
|
+
msg.wait_for_publish(timeout=timeout_s)
|
|
101
|
+
return msg
|
|
102
|
+
|
|
103
|
+
def disconnect(self):
|
|
104
|
+
self.client.loop_stop()
|
|
105
|
+
self.client.disconnect()
|
|
106
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
_root = os.path.dirname(__file__)
|
|
4
|
+
_par_id_file = os.path.abspath(os.path.join(_root, '..', 'data', 'ParaIDs.csv'))
|
|
5
|
+
assert os.path.exists(_par_id_file), "par-id file not found: please re-install PyTRMS package"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import logging as _logging
|
|
9
|
+
|
|
10
|
+
_logging.TRACE = 0 # overwrites logging.NOTSET
|
|
11
|
+
|
|
12
|
+
def enable_extended_logging(log_level=_logging.DEBUG):
|
|
13
|
+
'''make output of http-requests more talkative.
|
|
14
|
+
|
|
15
|
+
set 'log_level=logging.TRACE' (defined as 0 in pytrms.__init__) for highest verbosity!
|
|
16
|
+
'''
|
|
17
|
+
if log_level <= _logging.DEBUG:
|
|
18
|
+
# enable logging of http request urls on the library, that is
|
|
19
|
+
# underlying the 'requests'-package:
|
|
20
|
+
_logging.warn(f"enabling logging-output on 'urllib3' ({log_level = })")
|
|
21
|
+
requests_log = _logging.getLogger("urllib3")
|
|
22
|
+
requests_log.setLevel(log_level)
|
|
23
|
+
requests_log.propagate = True
|
|
24
|
+
|
|
25
|
+
if log_level == _logging.TRACE:
|
|
26
|
+
# Enabling debugging at http.client level (requests->urllib3->http.client)
|
|
27
|
+
# you will see the REQUEST, including HEADERS and DATA, and RESPONSE with
|
|
28
|
+
# HEADERS but without DATA. the only thing missing will be the response.body,
|
|
29
|
+
# which is not logged.
|
|
30
|
+
_logging.warn(f"enabling logging-output on 'HTTPConnection' ({log_level = })")
|
|
31
|
+
from http.client import HTTPConnection
|
|
32
|
+
HTTPConnection.debuglevel = 1
|
|
33
|
+
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
|
|
6
|
+
from . import _logging
|
|
7
|
+
from .._base import IoniClientBase
|
|
8
|
+
|
|
9
|
+
log = _logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# TODO :: sowas waer auch ganz cool: die DBAPI bietes sich geradezu an,
|
|
12
|
+
# da mehr object-oriented zu arbeiten:
|
|
13
|
+
# currentVariable = get_component(currentComponentNameAction, ds)
|
|
14
|
+
# currentVariable.save_value({'value': currentValue})
|
|
15
|
+
|
|
16
|
+
class IoniConnect(IoniClientBase):
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def is_connected(self):
|
|
20
|
+
'''Returns `True` if connection to IoniTOF could be established.'''
|
|
21
|
+
try:
|
|
22
|
+
self.get("/api/status")
|
|
23
|
+
return True
|
|
24
|
+
except:
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def is_running(self):
|
|
29
|
+
'''Returns `True` if IoniTOF is currently acquiring data.'''
|
|
30
|
+
raise NotImplementedError("is_running")
|
|
31
|
+
|
|
32
|
+
def connect(self, timeout_s):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
def disconnect(self):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
def __init__(self, host='127.0.0.1', port=5066, session=None):
|
|
39
|
+
super().__init__(host, port)
|
|
40
|
+
self.url = f"http://{self.host}:{self.port}"
|
|
41
|
+
if session is None:
|
|
42
|
+
session = requests.sessions.Session()
|
|
43
|
+
self.session = session
|
|
44
|
+
# ??
|
|
45
|
+
self.current_avg_endpoint = None
|
|
46
|
+
self.comp_dict = dict()
|
|
47
|
+
|
|
48
|
+
def get(self, endpoint, **kwargs):
|
|
49
|
+
return self._get_object(endpoint, **kwargs).json()
|
|
50
|
+
|
|
51
|
+
def post(self, endpoint, data, **kwargs):
|
|
52
|
+
return self._create_object(endpoint, data, 'post', **kwargs).headers.get('Location')
|
|
53
|
+
|
|
54
|
+
def put(self, endpoint, data, **kwargs):
|
|
55
|
+
return self._create_object(endpoint, data, 'put', **kwargs).headers.get('Location')
|
|
56
|
+
|
|
57
|
+
def upload(self, endpoint, filename):
|
|
58
|
+
if not endpoint.startswith('/'):
|
|
59
|
+
endpoint = '/' + endpoint
|
|
60
|
+
with open(filename) as f:
|
|
61
|
+
# Note (important!): this is a "form-data" entry, where the server
|
|
62
|
+
# expects the "name" to be 'file' and rejects it otherwise:
|
|
63
|
+
name = 'file'
|
|
64
|
+
r = self.session.post(self.url + endpoint, files=[(name, (filename, f, ''))])
|
|
65
|
+
r.raise_for_status()
|
|
66
|
+
|
|
67
|
+
return r
|
|
68
|
+
|
|
69
|
+
def _get_object(self, endpoint, **kwargs):
|
|
70
|
+
if not endpoint.startswith('/'):
|
|
71
|
+
endpoint = '/' + endpoint
|
|
72
|
+
if 'headers' not in kwargs:
|
|
73
|
+
kwargs['headers'] = {'content-type': 'application/hal+json'}
|
|
74
|
+
elif 'content-type' not in (k.lower() for k in kwargs['headers']):
|
|
75
|
+
kwargs['headers'].update({'content-type': 'application/hal+json'})
|
|
76
|
+
r = self.session.request('get', self.url + endpoint, **kwargs)
|
|
77
|
+
r.raise_for_status()
|
|
78
|
+
|
|
79
|
+
return r
|
|
80
|
+
|
|
81
|
+
def _create_object(self, endpoint, data, method='post', **kwargs):
|
|
82
|
+
if not endpoint.startswith('/'):
|
|
83
|
+
endpoint = '/' + endpoint
|
|
84
|
+
if not isinstance(data, str):
|
|
85
|
+
data = json.dumps(data)
|
|
86
|
+
if 'headers' not in kwargs:
|
|
87
|
+
kwargs['headers'] = {'content-type': 'application/hal+json'}
|
|
88
|
+
elif 'content-type' not in (k.lower() for k in kwargs['headers']):
|
|
89
|
+
kwargs['headers'].update({'content-type': 'application/hal+json'})
|
|
90
|
+
r = self.session.request(method, self.url + endpoint, data=data, **kwargs)
|
|
91
|
+
if not r.ok:
|
|
92
|
+
log.error(f"POST {endpoint}\n{data}\n\nreturned [{r.status_code}]: {r.content}")
|
|
93
|
+
r.raise_for_status()
|
|
94
|
+
|
|
95
|
+
return r
|
|
96
|
+
|
|
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
|
|
130
|
+
}
|
|
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.
|
|
146
|
+
|
|
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
|
+
]
|
|
159
|
+
}
|
|
160
|
+
endpoint = self.current_avg_endpoint + '/component_traces'
|
|
161
|
+
self.put(endpoint, payload)
|
|
162
|
+
|
|
163
|
+
def save_instrument_values(self, new_values):
|
|
164
|
+
"""
|
|
165
|
+
Post Parameters to the database.
|
|
166
|
+
|
|
167
|
+
`new_values` dictionary {name~>value}
|
|
168
|
+
"""
|
|
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)
|
|
186
|
+
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
|