leaf-framework 0.11.1__tar.gz → 0.12.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.
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/PKG-INFO +2 -2
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/continuous_experiment_adapter.py +1 -1
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/streaming_adapter.py +2 -2
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/equipment_adapter.py +6 -1
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/ble_watcher.py +1 -1
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/file_watcher.py +3 -3
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/lads_watcher.py +2 -2
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/modbus_watcher.py +1 -1
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/mqtt_watcher.py +3 -3
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/opc_watcher.py +2 -2
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/polling_watcher.py +8 -5
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/topic_store.py +2 -17
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/measure.py +71 -37
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/start.py +2 -2
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/stop.py +2 -2
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/upload_module.py +3 -3
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/equipment_actions.yaml +5 -4
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/metadata.py +13 -10
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/topic_utilities.py +1 -1
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/control.py +12 -6
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/monitoring.py +50 -39
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/pyproject.toml +2 -2
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/LICENSE +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/README.md +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/control_spec.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/discrete_experiment_adapter.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/upload_adapter.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/config/configuration.yaml +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/data/marketplace.yaml +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/error_handler/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/error_handler/error_holder.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/error_handler/exceptions.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/measurement_handler/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/measurement_handler/measurements.yaml +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/measurement_handler/terms.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/.gitkeep +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/event_watcher.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/external_api_watcher.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/external_event_watcher.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/http_watcher.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/mqtt_external_event_watcher.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/simple_watcher.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/carbon_dioxide.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/dissolved_oxygen.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/fluorescence.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/measurement_module.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/optical_density.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/ph.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/temperature.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/file.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/mqtt.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/output_module.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/sql.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/control.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/error.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/external_event_phase.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/initialisation.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/phase.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/continous_module.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/discrete_module.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/external_event_process.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/process_module.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/abstract_leaf.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/equipment_data.yaml +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/register/py.typed +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/registry/discovery.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/registry/loader.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/registry/registry.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/registry/utils.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/start.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/adapter_ui_extension.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/auth.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/components/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/components/app_control.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/components/header.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/components/update_notice.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/constants.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/desktop_launcher.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/images/icon.icns +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/images/icon.ico +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/images/icon.svg +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/interface.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/markdown/configuration_help.md +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/markdown/documentation_main.md +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/markdown/documentation_protips.md +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/scripts.js +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/styles.css +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/adapters.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/configuration.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/documentation.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/tabs/logs.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/update_checker.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/ui/utils.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/utility/config_utils.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/utility/logger/__init__.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/utility/logger/logger_utils.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/utility/running_adapters.py +0 -0
- {leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/utility/running_utilities.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: leaf-framework
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Lightweight Adapter Framework (LEAF)
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
License-File: LICENSE
|
|
@@ -15,7 +15,7 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
15
15
|
Requires-Dist: cairosvg (>=2.9.1,<3.0.0)
|
|
16
16
|
Requires-Dist: dateparser (>=1.4.3,<1.5.0)
|
|
17
17
|
Requires-Dist: httpx (>=0.28.1,<0.29.0)
|
|
18
|
-
Requires-Dist: leafobject (>=0.0
|
|
18
|
+
Requires-Dist: leafobject (>=0.1.0,<0.2.0)
|
|
19
19
|
Requires-Dist: nicegui (>=3.18.0,<3.19.0)
|
|
20
20
|
Requires-Dist: paho-mqtt (>=2.1.0,<2.2.0)
|
|
21
21
|
Requires-Dist: pillow (>=13.0.0,<14.0.0)
|
|
@@ -87,6 +87,6 @@ class ContinuousExperimentAdapter(EquipmentAdapter):
|
|
|
87
87
|
Injects a synthetic start message via the control process
|
|
88
88
|
to initialise any start-specific logic or output.
|
|
89
89
|
"""
|
|
90
|
-
start_topic = self._metadata_manager.
|
|
90
|
+
start_topic = self._metadata_manager.start
|
|
91
91
|
self._control_process.process_input(start_topic, {})
|
|
92
92
|
super().start()
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/streaming_adapter.py
RENAMED
|
@@ -159,9 +159,9 @@ class StreamingAdapter(EquipmentAdapter):
|
|
|
159
159
|
is active for a filename, further non-start/stop events for it are
|
|
160
160
|
no-ops; the running stream() call handles tailing on its own.
|
|
161
161
|
"""
|
|
162
|
-
if topic() == self._metadata_manager.
|
|
162
|
+
if topic() == self._metadata_manager.start():
|
|
163
163
|
self._launch_stream(data)
|
|
164
|
-
elif topic() == self._metadata_manager.
|
|
164
|
+
elif topic() == self._metadata_manager.stop():
|
|
165
165
|
self._stop_stream(data)
|
|
166
166
|
else:
|
|
167
167
|
with self._streams_guard:
|
|
@@ -293,7 +293,12 @@ class EquipmentAdapter(ABC):
|
|
|
293
293
|
"""Publish what happened to a control message on <control topic>/result, so it is stored with the data."""
|
|
294
294
|
# Never let the record break the command it describes
|
|
295
295
|
try:
|
|
296
|
-
|
|
296
|
+
# A control acts on the instance itself, so that is its entity unless the message names one
|
|
297
|
+
instance_tags = {
|
|
298
|
+
**self._metadata_manager.instance_tags(),
|
|
299
|
+
"entity": self._metadata_manager.get_instance_id(),
|
|
300
|
+
}
|
|
301
|
+
record = result_record(data, source, error, instance_tags)
|
|
297
302
|
_ = self._output.transmit(f"{topic}/result", record)
|
|
298
303
|
except Exception as e:
|
|
299
304
|
self._logger.warning(f"Could not publish the control record for {topic}: {e}")
|
|
@@ -231,7 +231,7 @@ class BLEEventWatcher(EventWatcher):
|
|
|
231
231
|
|
|
232
232
|
# Dispatch to callbacks using the measurement event
|
|
233
233
|
if self._metadata_manager:
|
|
234
|
-
self._dispatch_callback(self._metadata_manager.
|
|
234
|
+
self._dispatch_callback(self._metadata_manager.measurement, event_data)
|
|
235
235
|
else:
|
|
236
236
|
# Fallback: call callbacks directly with "measurement" event
|
|
237
237
|
for callback in self._callbacks:
|
|
@@ -131,9 +131,9 @@ class FileWatcher(FileSystemEventHandler, EventWatcher):
|
|
|
131
131
|
self._pending_timers: dict = {} # Track pending trailing timers per file path
|
|
132
132
|
|
|
133
133
|
self._term_map = {
|
|
134
|
-
self.on_created: metadata_manager.
|
|
135
|
-
self.on_modified: metadata_manager.
|
|
136
|
-
self.on_deleted: metadata_manager.
|
|
134
|
+
self.on_created: metadata_manager.start,
|
|
135
|
+
self.on_modified: metadata_manager.measurement,
|
|
136
|
+
self.on_deleted: metadata_manager.stop,
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
def start(self) -> None:
|
|
@@ -100,7 +100,7 @@ class LADSWatcher(EventWatcher):
|
|
|
100
100
|
self.logger.debug(f"LADS datachange_notification: node={nodeid}, value={val}")
|
|
101
101
|
info = self._variables.get(nodeid, {})
|
|
102
102
|
self._dispatch_callback(
|
|
103
|
-
self._metadata_manager.
|
|
103
|
+
self._metadata_manager.measurement,
|
|
104
104
|
{
|
|
105
105
|
"functional_unit": info.get("functional_unit"),
|
|
106
106
|
"node": node.nodeid.Identifier,
|
|
@@ -321,7 +321,7 @@ class LADSWatcher(EventWatcher):
|
|
|
321
321
|
self.logger.info(f"Subscribed to: {info['functional_unit']}/{info['browse_name']} ({nodeid})")
|
|
322
322
|
# Send a dummy value to trigger the callback
|
|
323
323
|
self._dispatch_callback(
|
|
324
|
-
self._metadata_manager.
|
|
324
|
+
self._metadata_manager.measurement,
|
|
325
325
|
{
|
|
326
326
|
"functional_unit": info["functional_unit"],
|
|
327
327
|
"node": node.nodeid.Identifier,
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/modbus_watcher.py
RENAMED
|
@@ -170,7 +170,7 @@ class ModbusWatcher(ExternalEventWatcher):
|
|
|
170
170
|
|
|
171
171
|
def _emit(self, data: dict[str, Any]) -> None:
|
|
172
172
|
if self._metadata_manager:
|
|
173
|
-
self._dispatch_callback(self._metadata_manager.
|
|
173
|
+
self._dispatch_callback(self._metadata_manager.measurement, data)
|
|
174
174
|
else:
|
|
175
175
|
for callback in self._callbacks:
|
|
176
176
|
try:
|
|
@@ -92,9 +92,9 @@ class MQTTEventWatcher(EventWatcher):
|
|
|
92
92
|
|
|
93
93
|
# topic -> list of events
|
|
94
94
|
self._topic_event_map: dict[str, list[str]] = {}
|
|
95
|
-
self._register_topics(start_topics, metadata_manager.
|
|
96
|
-
self._register_topics(measurement_topics, metadata_manager.
|
|
97
|
-
self._register_topics(stop_topics, metadata_manager.
|
|
95
|
+
self._register_topics(start_topics, metadata_manager.start)
|
|
96
|
+
self._register_topics(measurement_topics, metadata_manager.measurement)
|
|
97
|
+
self._register_topics(stop_topics, metadata_manager.stop)
|
|
98
98
|
self._register_topics(error_topics, metadata_manager.error)
|
|
99
99
|
|
|
100
100
|
# Set when on_connect fires so start() can block until subscriptions are active
|
|
@@ -77,7 +77,7 @@ class OPCWatcher(EventWatcher):
|
|
|
77
77
|
def datachange_notification(self, node: Node, val: int | str | float, data: DataChangeNotification) -> None:
|
|
78
78
|
self.logger.debug(f"OPC datachange_notification: node={node.nodeid.Identifier}, value={val}")
|
|
79
79
|
self._dispatch_callback(
|
|
80
|
-
self._metadata_manager.
|
|
80
|
+
self._metadata_manager.measurement,
|
|
81
81
|
{
|
|
82
82
|
"node": node.nodeid.Identifier,
|
|
83
83
|
"value": val,
|
|
@@ -233,7 +233,7 @@ class OPCWatcher(EventWatcher):
|
|
|
233
233
|
self.logger.info(f"Subscribed to: {node.nodeid.to_string()}")
|
|
234
234
|
# Send a dummy value to trigger the callback
|
|
235
235
|
self._dispatch_callback(
|
|
236
|
-
self._metadata_manager.
|
|
236
|
+
self._metadata_manager.measurement,
|
|
237
237
|
{
|
|
238
238
|
"node": node.nodeid.Identifier,
|
|
239
239
|
"value": node.get_value(),
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/polling_watcher.py
RENAMED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import threading
|
|
2
|
-
import time
|
|
3
2
|
from abc import abstractmethod
|
|
4
3
|
from collections.abc import Callable
|
|
5
4
|
from typing import Any
|
|
@@ -40,10 +39,12 @@ class PollingWatcher(EventWatcher):
|
|
|
40
39
|
|
|
41
40
|
self._interval: int = interval
|
|
42
41
|
self._thread: threading.Thread | None = None
|
|
42
|
+
# Set by stop() so the poll loop wakes at once instead of finishing a full interval.
|
|
43
|
+
self._stop_event: threading.Event = threading.Event()
|
|
43
44
|
self._term_map = {
|
|
44
|
-
self.start_message: metadata_manager.
|
|
45
|
-
self.stop_message: metadata_manager.
|
|
46
|
-
self.measurement_message: metadata_manager.
|
|
45
|
+
self.start_message: metadata_manager.start,
|
|
46
|
+
self.stop_message: metadata_manager.stop,
|
|
47
|
+
self.measurement_message: metadata_manager.measurement,
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
def start_message(self, data: Any) -> None:
|
|
@@ -99,7 +100,7 @@ class PollingWatcher(EventWatcher):
|
|
|
99
100
|
# reporting data.
|
|
100
101
|
pass
|
|
101
102
|
|
|
102
|
-
|
|
103
|
+
_ = self._stop_event.wait(self._interval)
|
|
103
104
|
|
|
104
105
|
def start(self) -> None:
|
|
105
106
|
"""
|
|
@@ -112,6 +113,7 @@ class PollingWatcher(EventWatcher):
|
|
|
112
113
|
return
|
|
113
114
|
|
|
114
115
|
self._running = True
|
|
116
|
+
self._stop_event.clear()
|
|
115
117
|
self._thread = threading.Thread(target=self._poll, daemon=True)
|
|
116
118
|
self._thread.start()
|
|
117
119
|
|
|
@@ -127,6 +129,7 @@ class PollingWatcher(EventWatcher):
|
|
|
127
129
|
return
|
|
128
130
|
|
|
129
131
|
self._running = False
|
|
132
|
+
self._stop_event.set()
|
|
130
133
|
if self._thread:
|
|
131
134
|
self._thread.join()
|
|
132
135
|
logger.info("PollingWatcher stopped.")
|
|
@@ -46,13 +46,12 @@ class TopicStore:
|
|
|
46
46
|
sample = _point_sample(point)
|
|
47
47
|
if sample is None:
|
|
48
48
|
continue
|
|
49
|
-
key = history_key(topic, point)
|
|
50
49
|
with self._history_lock:
|
|
51
|
-
history = self._history.setdefault(
|
|
50
|
+
history = self._history.setdefault(topic, deque(maxlen=HISTORY_LENGTH))
|
|
52
51
|
history.append(sample)
|
|
53
52
|
|
|
54
53
|
def get_history(self) -> dict[str, list[tuple[float, float]]]:
|
|
55
|
-
"""(timestamp, value) pairs per
|
|
54
|
+
"""(timestamp, value) pairs per topic, oldest first."""
|
|
56
55
|
with self._history_lock:
|
|
57
56
|
return {topic: list(points) for topic, points in self._history.items()}
|
|
58
57
|
|
|
@@ -83,20 +82,6 @@ class TopicStore:
|
|
|
83
82
|
return self._store
|
|
84
83
|
|
|
85
84
|
|
|
86
|
-
# Separates the topic from the point's entity tag in a history key
|
|
87
|
-
ENTITY_SEPARATOR = "#"
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
def history_key(topic: str, point: object) -> str:
|
|
91
|
-
"""Topic plus the point's "entity" tag, since one topic can carry several entities (e.g. the wells of a plate)."""
|
|
92
|
-
tags = cast(dict[str, object], point).get("tags")
|
|
93
|
-
if isinstance(tags, dict):
|
|
94
|
-
entity = cast(dict[str, object], tags).get("entity")
|
|
95
|
-
if isinstance(entity, str) and entity:
|
|
96
|
-
return f"{topic}{ENTITY_SEPARATOR}{entity}"
|
|
97
|
-
return topic
|
|
98
|
-
|
|
99
|
-
|
|
100
85
|
def _point_sample(point: object) -> tuple[float, float] | None:
|
|
101
86
|
"""(timestamp, value) of a LeafPoint JSON dict with a numeric fields.value, or None."""
|
|
102
87
|
if not isinstance(point, dict):
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import inspect
|
|
2
|
+
import re
|
|
2
3
|
import threading
|
|
3
4
|
import time
|
|
4
5
|
from typing import TYPE_CHECKING, Any, cast
|
|
@@ -35,11 +36,10 @@ class MeasurePhase(PhaseModule):
|
|
|
35
36
|
maximum_message_size (Optional[int]): The maximum number of measurements in a single message.
|
|
36
37
|
error_holder (Optional[ErrorHolder]): Optional, an error holder to manage phase errors.
|
|
37
38
|
"""
|
|
38
|
-
|
|
39
|
-
metadata_manager.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
)
|
|
39
|
+
if metadata_manager is not None:
|
|
40
|
+
term_builder = metadata_manager.measurement
|
|
41
|
+
else:
|
|
42
|
+
term_builder = "metadata_manager.measurement"
|
|
43
43
|
|
|
44
44
|
super().__init__(term_builder, metadata_manager=metadata_manager, error_holder=error_holder)
|
|
45
45
|
self._maximum_message_size: int = maximum_message_size
|
|
@@ -147,10 +147,10 @@ class MeasurePhase(PhaseModule):
|
|
|
147
147
|
finally:
|
|
148
148
|
self._measurement_lock.release()
|
|
149
149
|
else:
|
|
150
|
-
experiment_id = kwargs.get("experiment_id", "unknown")
|
|
151
150
|
measurement = kwargs.get("measurement", "unknown")
|
|
151
|
+
entity = kwargs.get("entity", self._instance_entity())
|
|
152
152
|
|
|
153
|
-
action = self._term_builder(
|
|
153
|
+
action = self._term_builder(entity=entity, measurement=measurement)
|
|
154
154
|
return [(action, data)]
|
|
155
155
|
|
|
156
156
|
def stream(self, output: "OutputModule", filename: Any) -> None:
|
|
@@ -218,11 +218,9 @@ class MeasurePhase(PhaseModule):
|
|
|
218
218
|
"""
|
|
219
219
|
Formulate one or more (topic, data) messages for a measurement result.
|
|
220
220
|
|
|
221
|
-
A list/set of points can mix several
|
|
222
|
-
(e.g.
|
|
223
|
-
encodes
|
|
224
|
-
the batch is split out into its own message rather than all being
|
|
225
|
-
published under whichever type happened to come first.
|
|
221
|
+
A list/set of points can mix several measurement types (e.g. pH,
|
|
222
|
+
temperature) and entities (e.g. reactors) in one batch. The topic
|
|
223
|
+
encodes one of each, so every distinct pair gets its own message.
|
|
226
224
|
|
|
227
225
|
Args:
|
|
228
226
|
experiment_id (str): The ID of the experiment.
|
|
@@ -232,56 +230,58 @@ class MeasurePhase(PhaseModule):
|
|
|
232
230
|
list[tuple]: A list of (action, result) tuples.
|
|
233
231
|
"""
|
|
234
232
|
if isinstance(result, dict):
|
|
235
|
-
self._add_instance_tags(cast(object, result))
|
|
236
|
-
|
|
237
|
-
action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
|
|
238
|
-
return [(action, result)]
|
|
233
|
+
self._add_instance_tags(cast(object, result), experiment_id)
|
|
234
|
+
return [(self._topic_of(result), result)]
|
|
239
235
|
|
|
240
236
|
payload = cast(object, result)
|
|
241
|
-
|
|
237
|
+
try:
|
|
238
|
+
point_json = point_json_of(self._with_entity(payload))
|
|
239
|
+
except ValueError as ex:
|
|
240
|
+
# A LeafPoint that fails validation, e.g. one without an entity tag
|
|
241
|
+
self._handle_exception(InterpreterError(str(ex)))
|
|
242
|
+
return []
|
|
242
243
|
if point_json is not None:
|
|
243
244
|
result = point_json
|
|
244
|
-
self._add_instance_tags(cast(object, result))
|
|
245
|
-
|
|
246
|
-
action = self._term_builder(experiment_id=experiment_id, measurement=measurement)
|
|
247
|
-
return [(action, result)]
|
|
245
|
+
self._add_instance_tags(cast(object, result), experiment_id)
|
|
246
|
+
return [(self._topic_of(result), result)]
|
|
248
247
|
|
|
249
248
|
if isinstance(result, list):
|
|
250
249
|
converted = []
|
|
251
250
|
for item in cast(list[object], result):
|
|
252
|
-
|
|
251
|
+
try:
|
|
252
|
+
point_json = point_json_of(self._with_entity(item))
|
|
253
|
+
except ValueError as ex:
|
|
254
|
+
# Only the invalid point is dropped, the rest of the batch is still sent
|
|
255
|
+
self._handle_exception(InterpreterError(str(ex)))
|
|
256
|
+
continue
|
|
253
257
|
if point_json is not None:
|
|
254
258
|
converted.append(point_json)
|
|
255
259
|
else:
|
|
256
260
|
converted.append(item)
|
|
257
261
|
|
|
262
|
+
# One message per topic, so per metric and entity
|
|
258
263
|
groups: dict[str, list] = {}
|
|
259
264
|
for item in converted:
|
|
260
|
-
self._add_instance_tags(cast(object, item))
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
else:
|
|
264
|
-
key = "unknown"
|
|
265
|
-
groups.setdefault(key, []).append(item)
|
|
266
|
-
|
|
267
|
-
messages = []
|
|
268
|
-
for key, group in groups.items():
|
|
269
|
-
action = self._term_builder(experiment_id=experiment_id, measurement=key)
|
|
270
|
-
messages.append((action, group))
|
|
271
|
-
return messages
|
|
265
|
+
self._add_instance_tags(cast(object, item), experiment_id)
|
|
266
|
+
groups.setdefault(self._topic_of(item), []).append(item)
|
|
267
|
+
return list(groups.items())
|
|
272
268
|
|
|
273
269
|
excp = AdapterLogicError(f"Unknown measurement data type: {type(result)}")
|
|
274
270
|
self._handle_exception(excp)
|
|
275
271
|
return []
|
|
276
272
|
|
|
277
|
-
def _add_instance_tags(self, item: object) -> None:
|
|
278
|
-
"""Tag a point with its instance's organisation and
|
|
273
|
+
def _add_instance_tags(self, item: object, experiment_id: str | None = None) -> None:
|
|
274
|
+
"""Tag a point with its instance's organisation, department, experiment id and entity, unless the adapter already did."""
|
|
279
275
|
if self._metadata_manager is None or not isinstance(item, dict) or "fields" not in item:
|
|
280
276
|
return
|
|
281
277
|
tags = cast(dict[str, object], item).setdefault("tags", {})
|
|
282
278
|
if not isinstance(tags, dict):
|
|
283
279
|
return
|
|
284
|
-
|
|
280
|
+
expected = self._metadata_manager.instance_tags()
|
|
281
|
+
# These defaults mean the adapter never set an experiment id
|
|
282
|
+
if experiment_id and experiment_id not in ("undefined", "invalid_id"):
|
|
283
|
+
expected["experiment_id"] = experiment_id
|
|
284
|
+
for key, value in expected.items():
|
|
285
285
|
current = cast(dict[str, object], tags).get(key)
|
|
286
286
|
if current is None:
|
|
287
287
|
tags[key] = value
|
|
@@ -291,6 +291,40 @@ class MeasurePhase(PhaseModule):
|
|
|
291
291
|
logger.warning(
|
|
292
292
|
f"Tag {key}={current!r} differs from the configured {key} {value!r}; keeping {current!r}"
|
|
293
293
|
)
|
|
294
|
+
# Every point has an entity; one inside the instance (e.g. a reactor) differs from it by design, so no warning
|
|
295
|
+
if not tags.get("entity"):
|
|
296
|
+
tags["entity"] = self._instance_entity()
|
|
297
|
+
|
|
298
|
+
def _with_entity(self, item: object) -> object:
|
|
299
|
+
"""Name the instance as the entity of a LeafPoint that names none."""
|
|
300
|
+
# Before to_json(), which rejects a point without an entity
|
|
301
|
+
tags: object = getattr(item, "tags", None)
|
|
302
|
+
if not isinstance(tags, dict):
|
|
303
|
+
return item
|
|
304
|
+
point_tags = cast(dict[str, object], tags)
|
|
305
|
+
if not point_tags.get("entity"):
|
|
306
|
+
point_tags["entity"] = self._instance_entity()
|
|
307
|
+
return item
|
|
308
|
+
|
|
309
|
+
def _topic_of(self, item: object) -> str:
|
|
310
|
+
"""Measurement topic of a point: .../measurement/<entity>/<metric>."""
|
|
311
|
+
if not isinstance(item, dict):
|
|
312
|
+
return self._term_builder(entity=self._instance_entity(), measurement="unknown")
|
|
313
|
+
return self._term_builder(entity=self._resolve_entity(item), measurement=self._resolve_measurement(item))
|
|
314
|
+
|
|
315
|
+
def _instance_entity(self) -> str:
|
|
316
|
+
"""Entity of a point that names none: the instance itself."""
|
|
317
|
+
if self._metadata_manager is None:
|
|
318
|
+
return "unknown"
|
|
319
|
+
return self._metadata_manager.get_instance_id() or "unknown"
|
|
320
|
+
|
|
321
|
+
def _resolve_entity(self, item: dict[str, Any]) -> str:
|
|
322
|
+
"""The point's "entity" tag, else the instance."""
|
|
323
|
+
tags = item.get("tags")
|
|
324
|
+
if not isinstance(tags, dict) or not tags.get("entity"):
|
|
325
|
+
return self._instance_entity()
|
|
326
|
+
# Level separators and wildcards would break the topic
|
|
327
|
+
return re.sub(r"[/+#]", "_", str(tags["entity"]))
|
|
294
328
|
|
|
295
329
|
def _resolve_measurement(self, item: dict) -> str:
|
|
296
330
|
"""
|
|
@@ -25,7 +25,7 @@ class StartPhase(ControlPhase):
|
|
|
25
25
|
metadata_manager (MetadataManager): Manages metadata associated with the phase.
|
|
26
26
|
error_holder (Optional[ErrorHolder]): Optional, an error holder to manage phase errors.
|
|
27
27
|
"""
|
|
28
|
-
term_builder = metadata_manager.
|
|
28
|
+
term_builder = metadata_manager.start
|
|
29
29
|
super().__init__(term_builder, metadata_manager=metadata_manager, error_holder=error_holder)
|
|
30
30
|
|
|
31
31
|
def update(self, data: Any) -> list:
|
|
@@ -48,5 +48,5 @@ class StartPhase(ControlPhase):
|
|
|
48
48
|
|
|
49
49
|
data = super().update(data)
|
|
50
50
|
data += [(self._metadata_manager.running(), True)]
|
|
51
|
-
data += [(self._metadata_manager.
|
|
51
|
+
data += [(self._metadata_manager.stop(), None)]
|
|
52
52
|
return data
|
|
@@ -25,7 +25,7 @@ class StopPhase(ControlPhase):
|
|
|
25
25
|
metadata_manager (MetadataManager): Manages metadata associated with the phase.
|
|
26
26
|
error_holder (Optional[ErrorHolder]): Optional, an error holder to manage phase errors.
|
|
27
27
|
"""
|
|
28
|
-
term_builder = metadata_manager.
|
|
28
|
+
term_builder = metadata_manager.stop
|
|
29
29
|
super().__init__(term_builder, metadata_manager=metadata_manager, error_holder=error_holder)
|
|
30
30
|
|
|
31
31
|
def update(self, data: Any) -> list:
|
|
@@ -47,5 +47,5 @@ class StopPhase(ControlPhase):
|
|
|
47
47
|
self._handle_exception(leaf_exp)
|
|
48
48
|
data = super().update(data)
|
|
49
49
|
data += [(self._metadata_manager.running(), False)]
|
|
50
|
-
data += [(self._metadata_manager.
|
|
50
|
+
data += [(self._metadata_manager.start(), None)]
|
|
51
51
|
return data
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/upload_module.py
RENAMED
|
@@ -43,7 +43,7 @@ class UploadProcess(DiscreteProcess):
|
|
|
43
43
|
topic (str): The topic to activate a specific phase.
|
|
44
44
|
data (dict): The data to be processed by the phase.
|
|
45
45
|
"""
|
|
46
|
-
if topic() != self._metadata_manager.
|
|
46
|
+
if topic() != self._metadata_manager.start():
|
|
47
47
|
assert topic() == self._metadata_manager.details()
|
|
48
48
|
return super().process_input(topic, data)
|
|
49
49
|
|
|
@@ -52,11 +52,11 @@ class UploadProcess(DiscreteProcess):
|
|
|
52
52
|
time.sleep(1)
|
|
53
53
|
|
|
54
54
|
# Measurements
|
|
55
|
-
measure_builder = self._metadata_manager.
|
|
55
|
+
measure_builder = self._metadata_manager.measurement
|
|
56
56
|
super().process_input(measure_builder, data)
|
|
57
57
|
time.sleep(1)
|
|
58
58
|
|
|
59
59
|
# Stop
|
|
60
|
-
stop_builder = self._metadata_manager.
|
|
60
|
+
stop_builder = self._metadata_manager.stop
|
|
61
61
|
super().process_input(stop_builder, data)
|
|
62
62
|
time.sleep(1)
|
|
@@ -4,7 +4,8 @@ heartbeat: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/heartbe
|
|
|
4
4
|
error: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/error
|
|
5
5
|
control: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/control
|
|
6
6
|
control_result: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/control/result
|
|
7
|
-
experiment
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
# A run (experiment, batch, upload) starts and stops on the instance itself; its id is a point tag
|
|
8
|
+
start: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/start
|
|
9
|
+
stop: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/stop
|
|
10
|
+
# The entity is what a point is about: the instance itself, or something inside it such as a reactor
|
|
11
|
+
measurement: <root>/<organisation>/<department>/<adapter_id>/<instance_id>/measurement/<entity>/<measurement>
|
|
@@ -117,8 +117,8 @@ class MetadataManager:
|
|
|
117
117
|
|
|
118
118
|
def __getattr__(self, name: str) -> Any:
|
|
119
119
|
"""
|
|
120
|
-
If the user does 'metadata_manager.
|
|
121
|
-
'
|
|
120
|
+
If the user does 'metadata_manager.start()', we look up
|
|
121
|
+
'start' in the TopicUtility's root group, and wrap it to
|
|
122
122
|
auto-fill placeholders with equipment metadata.
|
|
123
123
|
"""
|
|
124
124
|
try:
|
|
@@ -129,10 +129,7 @@ class MetadataManager:
|
|
|
129
129
|
if callable(sub):
|
|
130
130
|
|
|
131
131
|
def _wrapper(**overrides) -> str:
|
|
132
|
-
|
|
133
|
-
data.update(self.get_instance_data())
|
|
134
|
-
placeholders = {**data, **overrides}
|
|
135
|
-
return sub(**placeholders)
|
|
132
|
+
return cast(str, sub(**self._placeholders(overrides)))
|
|
136
133
|
|
|
137
134
|
return _wrapper
|
|
138
135
|
if isinstance(sub, _TemplateGroup):
|
|
@@ -140,6 +137,15 @@ class MetadataManager:
|
|
|
140
137
|
|
|
141
138
|
return sub
|
|
142
139
|
|
|
140
|
+
def _placeholders(self, overrides: dict[str, Any]) -> dict[str, Any]:
|
|
141
|
+
"""Template placeholders from the equipment and instance data, then the caller's overrides."""
|
|
142
|
+
data = self.get_equipment_data().copy()
|
|
143
|
+
data.update(self.get_instance_data())
|
|
144
|
+
# A point that names no entity is about the instance itself
|
|
145
|
+
if data.get("instance_id"):
|
|
146
|
+
data.setdefault("entity", data["instance_id"])
|
|
147
|
+
return {**data, **overrides}
|
|
148
|
+
|
|
143
149
|
|
|
144
150
|
class _ManagerGroup:
|
|
145
151
|
"""
|
|
@@ -156,10 +162,7 @@ class _ManagerGroup:
|
|
|
156
162
|
if callable(sub):
|
|
157
163
|
|
|
158
164
|
def _wrapper(**overrides) -> str:
|
|
159
|
-
|
|
160
|
-
data.update(self._manager.get_instance_data())
|
|
161
|
-
placeholders = {**data, **overrides}
|
|
162
|
-
return sub(**placeholders)
|
|
165
|
+
return cast(str, sub(**self._manager._placeholders(overrides))) # pyright: ignore[reportPrivateUsage]
|
|
163
166
|
|
|
164
167
|
return _wrapper
|
|
165
168
|
if isinstance(sub, _TemplateGroup):
|
|
@@ -68,7 +68,7 @@ class TopicUtility:
|
|
|
68
68
|
self._collect_templates(self._raw_dict)
|
|
69
69
|
self.root = _TemplateGroup(self._raw_dict, self)
|
|
70
70
|
|
|
71
|
-
def __getattr__(self, name: str):
|
|
71
|
+
def __getattr__(self, name: str) -> Any:
|
|
72
72
|
"""
|
|
73
73
|
Handle dynamic attribute access:
|
|
74
74
|
- If it's a required field from the YAML, return its name.
|
|
@@ -190,16 +190,22 @@ def controllable_instances() -> list[EquipmentAdapter]:
|
|
|
190
190
|
|
|
191
191
|
def _instance_card(adapter: EquipmentAdapter) -> None:
|
|
192
192
|
metadata = adapter.metadata_manager
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
)
|
|
193
|
+
title = f"{metadata.get_instance_id()} ({metadata.get_equipment_data('adapter_id')})"
|
|
194
|
+
# Foldable, so an instance with many controls can be tucked away
|
|
195
|
+
with (
|
|
196
|
+
ui.card().classes("leaf-card w-full mb-4"),
|
|
197
|
+
ui.expansion(title, caption=f"{len(adapter.controls)} controls", value=True)
|
|
198
|
+
.classes("w-full")
|
|
199
|
+
.props('header-class="text-lg font-semibold text-heading"'),
|
|
200
|
+
):
|
|
197
201
|
if not adapter.controls:
|
|
198
202
|
_ = ui.label(
|
|
199
203
|
"This adapter declares no controls in its device.json; send LeafPoints on the control topic instead."
|
|
200
204
|
).classes("text-body-secondary")
|
|
201
|
-
|
|
202
|
-
|
|
205
|
+
# Two columns on wide screens, one on narrow ones
|
|
206
|
+
with ui.element("div").classes("grid grid-cols-1 lg:grid-cols-2 gap-x-8 w-full"):
|
|
207
|
+
for control in adapter.controls:
|
|
208
|
+
_control_row(adapter, control)
|
|
203
209
|
|
|
204
210
|
|
|
205
211
|
def create_control_tab(control_tab: ui.tab) -> None:
|
|
@@ -3,7 +3,6 @@ from typing import Any, cast
|
|
|
3
3
|
from nicegui import ui
|
|
4
4
|
|
|
5
5
|
from leaf.modules.output_modules.output_module import OutputModule
|
|
6
|
-
from leaf.modules.output_modules.topic_store import ENTITY_SEPARATOR
|
|
7
6
|
|
|
8
7
|
|
|
9
8
|
def _store_to_tree_nodes(store: dict, parent_path: str = "") -> list[dict[str, Any]]:
|
|
@@ -25,9 +24,8 @@ def _store_to_tree_nodes(store: dict, parent_path: str = "") -> list[dict[str, A
|
|
|
25
24
|
|
|
26
25
|
|
|
27
26
|
def metric_name(key: str) -> str:
|
|
28
|
-
"""Last topic segment
|
|
29
|
-
|
|
30
|
-
return topic.rsplit("/", 1)[-1]
|
|
27
|
+
"""Last topic segment, e.g. "T" for ".../reactor1/measurement/well_A1/T"."""
|
|
28
|
+
return key.rsplit("/", 1)[-1]
|
|
31
29
|
|
|
32
30
|
|
|
33
31
|
def format_value(value: float) -> str:
|
|
@@ -40,40 +38,42 @@ def full_label(key: str) -> str:
|
|
|
40
38
|
return key.split("/", 1)[-1]
|
|
41
39
|
|
|
42
40
|
|
|
41
|
+
def measurement_levels(key: str) -> list[str]:
|
|
42
|
+
"""[instance, entity, metric] of a .../<instance_id>/measurement/<entity>/<metric> topic, else [].
|
|
43
|
+
|
|
44
|
+
The entity is left out when it only names the instance again, as for a single device.
|
|
45
|
+
"""
|
|
46
|
+
parts = key.split("/")
|
|
47
|
+
if len(parts) < 4 or parts[-3] != "measurement":
|
|
48
|
+
return []
|
|
49
|
+
instance, entity, metric = parts[-4], parts[-2], parts[-1]
|
|
50
|
+
if entity == instance:
|
|
51
|
+
return [instance, metric]
|
|
52
|
+
return [instance, entity, metric]
|
|
53
|
+
|
|
54
|
+
|
|
43
55
|
def instance_name(key: str) -> str:
|
|
44
|
-
"""Instance ID of a measurement topic,
|
|
45
|
-
|
|
46
|
-
if
|
|
47
|
-
return
|
|
48
|
-
return
|
|
56
|
+
"""Instance ID of a measurement topic, else ""."""
|
|
57
|
+
levels = measurement_levels(key)
|
|
58
|
+
if levels:
|
|
59
|
+
return levels[0]
|
|
60
|
+
return ""
|
|
49
61
|
|
|
50
62
|
|
|
51
|
-
def series_label(key: str
|
|
52
|
-
""" "<instance_id> · <entity> · <metric>" for a measurement topic, else the full label.
|
|
63
|
+
def series_label(key: str) -> str:
|
|
64
|
+
""" "<instance_id> · <entity> · <metric>" for a measurement topic, else the full label."""
|
|
65
|
+
levels = measurement_levels(key)
|
|
66
|
+
if levels:
|
|
67
|
+
return " · ".join(levels)
|
|
68
|
+
return full_label(key)
|
|
53
69
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"""
|
|
57
|
-
topic, _, entity = key.partition(ENTITY_SEPARATOR)
|
|
58
|
-
instance = instance_name(key)
|
|
59
|
-
if not instance:
|
|
60
|
-
return full_label(key)
|
|
61
|
-
# Instance stays in: entity names repeat across instances
|
|
62
|
-
names = [instance]
|
|
63
|
-
shared = [other for other in all_keys if other.partition(ENTITY_SEPARATOR)[0] == topic]
|
|
64
|
-
if entity and len(shared) > 1:
|
|
65
|
-
names.append(entity)
|
|
66
|
-
names.append(metric_name(topic))
|
|
67
|
-
return " · ".join(names)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def tile_label(key: str, all_keys: list[str]) -> str:
|
|
70
|
+
|
|
71
|
+
def tile_label(key: str) -> str:
|
|
71
72
|
"""Series label without the instance, which the tile's group box already names."""
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return label
|
|
73
|
+
levels = measurement_levels(key)
|
|
74
|
+
if levels:
|
|
75
|
+
return " · ".join(levels[1:])
|
|
76
|
+
return full_label(key)
|
|
77
77
|
|
|
78
78
|
|
|
79
79
|
# ECharts' default palette, assigned per metric rather than per selected line
|
|
@@ -92,7 +92,7 @@ def chart_series(history: dict[str, list[tuple[float, float]]], topics: list[str
|
|
|
92
92
|
points = [[timestamp * 1000, value] for timestamp, value in history.get(topic, [])]
|
|
93
93
|
series.append(
|
|
94
94
|
{
|
|
95
|
-
"name": series_label(topic
|
|
95
|
+
"name": series_label(topic),
|
|
96
96
|
"type": "line",
|
|
97
97
|
"showSymbol": False,
|
|
98
98
|
"color": metric_color(topic, list(history)),
|
|
@@ -136,6 +136,8 @@ def create_monitoring_tab(monitor_tab: ui.tab) -> None:
|
|
|
136
136
|
tiles = ui.row().classes("w-full gap-3")
|
|
137
137
|
tile_groups: dict[str, ui.row] = {}
|
|
138
138
|
tile_values: dict[str, ui.label] = {}
|
|
139
|
+
tile_expansions: dict[str, ui.expansion] = {}
|
|
140
|
+
tile_count: dict[str, int] = {}
|
|
139
141
|
|
|
140
142
|
# Metrics with different scales do not share an axis well, so the user picks which to plot
|
|
141
143
|
with ui.row().classes("w-full items-center no-wrap"):
|
|
@@ -179,19 +181,28 @@ def create_monitoring_tab(monitor_tab: ui.tab) -> None:
|
|
|
179
181
|
if topic not in tile_values:
|
|
180
182
|
instance = instance_name(topic)
|
|
181
183
|
if instance not in tile_groups:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
184
|
+
# Foldable, so an instance with many metrics can be tucked away
|
|
185
|
+
with (
|
|
186
|
+
tiles,
|
|
187
|
+
ui.card().props("flat bordered").classes("p-0"),
|
|
188
|
+
ui.expansion(instance or "Other", value=True).props(
|
|
189
|
+
'dense header-class="text-sm font-medium"'
|
|
190
|
+
) as group,
|
|
191
|
+
):
|
|
192
|
+
tile_groups[instance] = ui.row().classes("gap-3 p-3 pt-0")
|
|
193
|
+
tile_expansions[instance] = group
|
|
194
|
+
tile_count[instance] = tile_count.get(instance, 0) + 1
|
|
195
|
+
_ = tile_expansions[instance].props(f'caption="{tile_count[instance]} metrics"')
|
|
185
196
|
stripe = f"border-left: 4px solid {metric_color(topic, topics)}"
|
|
186
197
|
with (
|
|
187
198
|
tile_groups[instance],
|
|
188
199
|
ui.card().classes("p-3 min-w-32").style(stripe).tooltip(full_label(topic)),
|
|
189
200
|
):
|
|
190
|
-
_ = ui.label(tile_label(topic
|
|
201
|
+
_ = ui.label(tile_label(topic)).classes("text-xs text-body-secondary")
|
|
191
202
|
tile_values[topic] = ui.label().classes("text-2xl font-bold text-heading")
|
|
192
203
|
_ = tile_values[topic].set_text(format_value(history[topic][-1][1]))
|
|
193
204
|
# NiceGUI leaves the options type unparametrised
|
|
194
|
-
_ = metric_select.set_options({topic: series_label(topic
|
|
205
|
+
_ = metric_select.set_options({topic: series_label(topic) for topic in topics}) # pyright: ignore[reportUnknownMemberType]
|
|
195
206
|
selected = cast(list[str], metric_select.value or [])
|
|
196
207
|
if not selected and topics and not picked_default:
|
|
197
208
|
selected = [topics[0]]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "leaf-framework"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.12.0"
|
|
4
4
|
description = "Lightweight Adapter Framework (LEAF)"
|
|
5
5
|
authors = [
|
|
6
6
|
"Matthew Crowther <nmc215@ncl.ac.uk>",
|
|
@@ -33,7 +33,7 @@ requests = "~2.34.2"
|
|
|
33
33
|
watchdog = "~7.0.0"
|
|
34
34
|
cairosvg = "^2.9.1"
|
|
35
35
|
pillow = "^13.0.0"
|
|
36
|
-
leafobject = "^0.0
|
|
36
|
+
leafobject = "^0.1.0"
|
|
37
37
|
|
|
38
38
|
[tool.poetry.scripts]
|
|
39
39
|
leaf = "leaf.start:main"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/adapters/core_adapters/upload_adapter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/external_api_watcher.py
RENAMED
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/external_event_watcher.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/input_modules/simple_watcher.py
RENAMED
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/__init__.py
RENAMED
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/carbon_dioxide.py
RENAMED
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/dissolved_oxygen.py
RENAMED
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/fluorescence.py
RENAMED
|
File without changes
|
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/optical_density.py
RENAMED
|
File without changes
|
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/measurement_modules/temperature.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/output_modules/output_module.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/external_event_phase.py
RENAMED
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/phase_modules/initialisation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/continous_module.py
RENAMED
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/discrete_module.py
RENAMED
|
File without changes
|
|
File without changes
|
{leaf_framework-0.11.1 → leaf_framework-0.12.0}/leaf/modules/process_modules/process_module.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|