rtint-gui 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rtint_gui/__init__.py +8 -0
- rtint_gui/_version.py +24 -0
- rtint_gui/app.py +116 -0
- rtint_gui/connection.py +183 -0
- rtint_gui/filter_inventory.py +175 -0
- rtint_gui/loop_meta.py +366 -0
- rtint_gui/lutio.py +208 -0
- rtint_gui/main_window.py +454 -0
- rtint_gui/matio.py +1313 -0
- rtint_gui/pages/__init__.py +9 -0
- rtint_gui/pages/acquisition_engine.py +292 -0
- rtint_gui/pages/acquisition_page.py +420 -0
- rtint_gui/pages/base.py +263 -0
- rtint_gui/pages/closed_loop_analysis.py +429 -0
- rtint_gui/pages/counters_page.py +641 -0
- rtint_gui/pages/custom_page.py +432 -0
- rtint_gui/pages/dynamical_identification_page.py +3244 -0
- rtint_gui/pages/ethercat_page.py +377 -0
- rtint_gui/pages/feedback_analysis.py +304 -0
- rtint_gui/pages/feedback_mimo_analysis.py +689 -0
- rtint_gui/pages/feedback_mimo_page.py +2632 -0
- rtint_gui/pages/filters_page.py +2272 -0
- rtint_gui/pages/generators_page.py +3183 -0
- rtint_gui/pages/lut_page.py +1119 -0
- rtint_gui/pages/motors_page.py +2361 -0
- rtint_gui/pages/parameters_page.py +465 -0
- rtint_gui/pages/programs_page.py +778 -0
- rtint_gui/pages/regul_page.py +2031 -0
- rtint_gui/pages/signal_analysis_page.py +2913 -0
- rtint_gui/pages/tf_analysis_page.py +1918 -0
- rtint_gui/pages/triggers_page.py +281 -0
- rtint_gui/pages/utils_analysis.py +446 -0
- rtint_gui/styles.py +25 -0
- rtint_gui/widgets/__init__.py +0 -0
- rtint_gui/widgets/asd_editor.py +445 -0
- rtint_gui/widgets/axis_align.py +55 -0
- rtint_gui/widgets/bode_plot.py +546 -0
- rtint_gui/widgets/bode_tuning.py +1071 -0
- rtint_gui/widgets/characteristic_loci_plot.py +300 -0
- rtint_gui/widgets/color_choice.py +99 -0
- rtint_gui/widgets/comment_dialog.py +69 -0
- rtint_gui/widgets/counter_picker.py +258 -0
- rtint_gui/widgets/custom_dash.py +1333 -0
- rtint_gui/widgets/custom_ethercat_control_widget.py +334 -0
- rtint_gui/widgets/custom_ethercat_widget.py +287 -0
- rtint_gui/widgets/custom_generator_widget.py +526 -0
- rtint_gui/widgets/custom_lut_widget.py +523 -0
- rtint_gui/widgets/custom_rt_widget.py +931 -0
- rtint_gui/widgets/custom_script_button.py +380 -0
- rtint_gui/widgets/custom_signal_display.py +464 -0
- rtint_gui/widgets/custom_time_widget.py +928 -0
- rtint_gui/widgets/custom_widgets.py +1893 -0
- rtint_gui/widgets/entity_table.py +178 -0
- rtint_gui/widgets/filter_matrix_selector.py +208 -0
- rtint_gui/widgets/filter_param_form.py +200 -0
- rtint_gui/widgets/lazy_tree.py +299 -0
- rtint_gui/widgets/loop_diagram.py +450 -0
- rtint_gui/widgets/nyquist_plot.py +538 -0
- rtint_gui/widgets/parameter_picker.py +208 -0
- rtint_gui/widgets/plant_table.py +404 -0
- rtint_gui/widgets/plot_axis.py +170 -0
- rtint_gui/widgets/plot_colors.py +75 -0
- rtint_gui/widgets/plot_overlay.py +164 -0
- rtint_gui/widgets/plot_readout.py +304 -0
- rtint_gui/widgets/plot_view.py +65 -0
- rtint_gui/widgets/signal_group_picker.py +322 -0
- rtint_gui/widgets/tuning_host.py +136 -0
- rtint_gui/widgets/validated_line.py +286 -0
- rtint_gui/worker.py +444 -0
- rtint_gui-0.0.1.dist-info/METADATA +53 -0
- rtint_gui-0.0.1.dist-info/RECORD +74 -0
- rtint_gui-0.0.1.dist-info/WHEEL +5 -0
- rtint_gui-0.0.1.dist-info/entry_points.txt +2 -0
- rtint_gui-0.0.1.dist-info/top_level.txt +1 -0
rtint_gui/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""rtint-gui — desktop GUI for the rtint real-time target interface."""
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
# Populated by setuptools-scm at build/install time (see pyproject.toml's
|
|
5
|
+
# [tool.setuptools_scm].version_file); matches the most recent git tag.
|
|
6
|
+
from rtint_gui._version import __version__
|
|
7
|
+
except ImportError: # pragma: no cover - dev checkout without a build
|
|
8
|
+
__version__ = "0.0.1"
|
rtint_gui/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.0.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
rtint_gui/app.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Application entry point.
|
|
2
|
+
|
|
3
|
+
``rtint-gui`` console script: connect to a real-time target (optionally via
|
|
4
|
+
``--server host:port``) and show the main window.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import QTimer
|
|
13
|
+
from PySide6.QtWidgets import QApplication, QMessageBox
|
|
14
|
+
|
|
15
|
+
from rtint_gui import __version__
|
|
16
|
+
from rtint_gui.connection import ConnectDialog
|
|
17
|
+
from rtint_gui.main_window import MainWindow
|
|
18
|
+
from rtint_gui.worker import RtintWorker, warm_rtint
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_server(server_str: str) -> tuple[str, int]:
|
|
22
|
+
if ":" not in server_str:
|
|
23
|
+
raise ValueError("expected HOST:PORT (e.g. 192.168.0.10:5000)")
|
|
24
|
+
host, port_s = server_str.rsplit(":", 1)
|
|
25
|
+
host = host.strip()
|
|
26
|
+
if not host:
|
|
27
|
+
raise ValueError("host is empty")
|
|
28
|
+
try:
|
|
29
|
+
port = int(port_s)
|
|
30
|
+
except ValueError as exc:
|
|
31
|
+
raise ValueError(f"invalid port: {port_s!r}") from exc
|
|
32
|
+
if not 1 <= port <= 65535:
|
|
33
|
+
raise ValueError("port out of range (1..65535)")
|
|
34
|
+
return host, port
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
prog="rtint-gui",
|
|
40
|
+
description=f"Desktop GUI for the rtint real-time target interface (v{__version__}).",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--server",
|
|
44
|
+
metavar="HOST:PORT",
|
|
45
|
+
help="real-time target server and port, e.g. 192.168.0.10:5000. "
|
|
46
|
+
"When omitted a connection dialog is shown.",
|
|
47
|
+
)
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: list[str] | None = None) -> int:
|
|
52
|
+
parser = _build_parser()
|
|
53
|
+
args = parser.parse_args(argv)
|
|
54
|
+
|
|
55
|
+
app = QApplication(sys.argv if argv is None else argv)
|
|
56
|
+
app.setApplicationName("rtint-gui")
|
|
57
|
+
|
|
58
|
+
worker = RtintWorker()
|
|
59
|
+
holder: list = [] # keep windows alive for the lifetime of the app
|
|
60
|
+
dialogs: dict = {}
|
|
61
|
+
|
|
62
|
+
def show_main_window(w: RtintWorker) -> None:
|
|
63
|
+
window = MainWindow(w)
|
|
64
|
+
window.show()
|
|
65
|
+
holder[:] = [window]
|
|
66
|
+
|
|
67
|
+
def show_connect_dialog(error_text: str | None = None) -> None:
|
|
68
|
+
dlg = ConnectDialog(
|
|
69
|
+
worker,
|
|
70
|
+
on_connected=_on_connected,
|
|
71
|
+
initial_error=error_text,
|
|
72
|
+
)
|
|
73
|
+
dialogs["connect"] = dlg
|
|
74
|
+
holder[:] = [dlg]
|
|
75
|
+
dlg.show()
|
|
76
|
+
|
|
77
|
+
def _on_connected(w: RtintWorker) -> None:
|
|
78
|
+
dlg = dialogs.get("connect")
|
|
79
|
+
if dlg is not None:
|
|
80
|
+
dlg.accept()
|
|
81
|
+
show_main_window(w)
|
|
82
|
+
|
|
83
|
+
if args.server:
|
|
84
|
+
try:
|
|
85
|
+
host, port = _parse_server(args.server)
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
QMessageBox.warning(None, "rtint-gui", f"Invalid --server value: {exc}")
|
|
88
|
+
show_connect_dialog()
|
|
89
|
+
else:
|
|
90
|
+
worker.connect_controller(
|
|
91
|
+
host,
|
|
92
|
+
port,
|
|
93
|
+
# on_done receives the new RtintController; the window needs
|
|
94
|
+
# the worker itself.
|
|
95
|
+
on_done=lambda _ctl: show_main_window(worker),
|
|
96
|
+
on_error=show_connect_dialog,
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
show_connect_dialog()
|
|
100
|
+
|
|
101
|
+
# Constructor/connection exceptions never crash the app: the worker routes
|
|
102
|
+
# them to on_error, which falls back to the ConnectDialog.
|
|
103
|
+
|
|
104
|
+
# Defer the ~1 s rtint import until after the first dialog is painted. It
|
|
105
|
+
# still runs on the GUI thread exactly once; connect_controller joins on it
|
|
106
|
+
# if the user clicks Connect before this fires.
|
|
107
|
+
QTimer.singleShot(0, warm_rtint)
|
|
108
|
+
|
|
109
|
+
app.aboutToQuit.connect(worker.stop)
|
|
110
|
+
exit_code = app.exec()
|
|
111
|
+
worker.wait(5000)
|
|
112
|
+
return exit_code
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
sys.exit(main())
|
rtint_gui/connection.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Startup connection dialog and reconnect overlay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import Qt, Signal
|
|
6
|
+
from PySide6.QtWidgets import (
|
|
7
|
+
QDialog,
|
|
8
|
+
QFormLayout,
|
|
9
|
+
QLabel,
|
|
10
|
+
QLineEdit,
|
|
11
|
+
QPushButton,
|
|
12
|
+
QSpinBox,
|
|
13
|
+
QVBoxLayout,
|
|
14
|
+
QWidget,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConnectDialog(QDialog):
|
|
19
|
+
"""Modal-ish startup dialog: host / port / Connect.
|
|
20
|
+
|
|
21
|
+
Connecting runs through ``worker.connect_controller`` (non-blocking, the
|
|
22
|
+
status label shows "Connecting…"). On success the worker is handed onward
|
|
23
|
+
via the ``on_connected`` callback given by the application; on failure the
|
|
24
|
+
dialog stays open and shows the error text.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
worker,
|
|
30
|
+
on_connected=None,
|
|
31
|
+
initial_error: str | None = None,
|
|
32
|
+
parent: QWidget | None = None,
|
|
33
|
+
):
|
|
34
|
+
super().__init__(parent)
|
|
35
|
+
self.worker = worker
|
|
36
|
+
self.on_connected = on_connected
|
|
37
|
+
self.setWindowTitle("rtint-gui — Connect to real-time target")
|
|
38
|
+
self.setMinimumWidth(420)
|
|
39
|
+
|
|
40
|
+
form = QFormLayout()
|
|
41
|
+
self.host_edit = QLineEdit(self)
|
|
42
|
+
self.host_edit.setPlaceholderText("server host, e.g. 192.168.0.10")
|
|
43
|
+
self.port_spin = QSpinBox(self)
|
|
44
|
+
self.port_spin.setRange(1, 65535)
|
|
45
|
+
self.port_spin.setValue(5000)
|
|
46
|
+
self.connect_btn = QPushButton("Connect", self)
|
|
47
|
+
self.status_label = QLabel(self)
|
|
48
|
+
self.status_label.setWordWrap(True)
|
|
49
|
+
if initial_error:
|
|
50
|
+
self._set_error(str(initial_error))
|
|
51
|
+
|
|
52
|
+
form.addRow("Server:", self.host_edit)
|
|
53
|
+
form.addRow("Port:", self.port_spin)
|
|
54
|
+
form.addRow(self.connect_btn)
|
|
55
|
+
|
|
56
|
+
layout = QVBoxLayout(self)
|
|
57
|
+
layout.addLayout(form)
|
|
58
|
+
layout.addWidget(self.status_label)
|
|
59
|
+
|
|
60
|
+
self.connect_btn.clicked.connect(self._on_connect_clicked)
|
|
61
|
+
|
|
62
|
+
def _set_error(self, text: str) -> None:
|
|
63
|
+
self.status_label.setText(text)
|
|
64
|
+
self.status_label.setStyleSheet("color: #c62828;")
|
|
65
|
+
|
|
66
|
+
def _on_connect_clicked(self) -> None:
|
|
67
|
+
host = self.host_edit.text().strip()
|
|
68
|
+
if not host:
|
|
69
|
+
self._set_error("Please enter the real-time target host.")
|
|
70
|
+
return
|
|
71
|
+
self.connect_btn.setEnabled(False)
|
|
72
|
+
self.status_label.setStyleSheet("")
|
|
73
|
+
self.status_label.setText("Connecting…")
|
|
74
|
+
self.worker.connect_controller(
|
|
75
|
+
host,
|
|
76
|
+
self.port_spin.value(),
|
|
77
|
+
on_done=self._on_connect_ok,
|
|
78
|
+
on_error=self._on_connect_error,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def _on_connect_ok(self, controller) -> None:
|
|
82
|
+
self.connect_btn.setEnabled(True)
|
|
83
|
+
if self.on_connected is not None:
|
|
84
|
+
self.on_connected(self.worker)
|
|
85
|
+
|
|
86
|
+
def _on_connect_error(self, exc_str: str) -> None:
|
|
87
|
+
self.connect_btn.setEnabled(True)
|
|
88
|
+
self._set_error(str(exc_str))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ReconnectOverlay(QWidget):
|
|
92
|
+
"""Semi-transparent overlay offering a Reconnect button after the
|
|
93
|
+
connection to the real-time target is lost.
|
|
94
|
+
|
|
95
|
+
``attach_to(main_window)`` parents it to the central widget and keeps it
|
|
96
|
+
sized to the central area. Reconnect retries the last known server/port
|
|
97
|
+
through the worker and hides on success.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
closed = Signal()
|
|
101
|
+
|
|
102
|
+
def __init__(self, worker, parent: QWidget | None = None):
|
|
103
|
+
super().__init__(parent)
|
|
104
|
+
self.worker = worker
|
|
105
|
+
self._main_window = None
|
|
106
|
+
self.setObjectName("reconnect_overlay")
|
|
107
|
+
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
|
108
|
+
self.setStyleSheet(
|
|
109
|
+
"#reconnect_overlay { background-color: rgba(35, 35, 40, 210); }"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
title = QLabel("Connection lost", self)
|
|
113
|
+
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
114
|
+
title.setStyleSheet("color: white; font-size: 20px; font-weight: bold;")
|
|
115
|
+
hint = QLabel(
|
|
116
|
+
"The link to the real-time target has been interrupted.", self
|
|
117
|
+
)
|
|
118
|
+
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
119
|
+
hint.setStyleSheet("color: #d0d0d0;")
|
|
120
|
+
self.reconnect_btn = QPushButton("Reconnect", self)
|
|
121
|
+
self.reconnect_btn.setMinimumWidth(160)
|
|
122
|
+
self.status_label = QLabel("", self)
|
|
123
|
+
self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
124
|
+
self.status_label.setWordWrap(True)
|
|
125
|
+
self.status_label.setStyleSheet("color: #ffb4b4;")
|
|
126
|
+
|
|
127
|
+
layout = QVBoxLayout(self)
|
|
128
|
+
layout.addStretch(1)
|
|
129
|
+
layout.addWidget(title)
|
|
130
|
+
layout.addWidget(hint)
|
|
131
|
+
layout.addSpacing(12)
|
|
132
|
+
layout.addWidget(self.reconnect_btn, 0, Qt.AlignmentFlag.AlignCenter)
|
|
133
|
+
layout.addWidget(self.status_label)
|
|
134
|
+
layout.addStretch(1)
|
|
135
|
+
|
|
136
|
+
self.reconnect_btn.clicked.connect(self._on_reconnect_clicked)
|
|
137
|
+
self.hide()
|
|
138
|
+
|
|
139
|
+
def attach_to(self, main_window) -> None:
|
|
140
|
+
"""Parent the overlay to the main window's central widget."""
|
|
141
|
+
self._main_window = main_window
|
|
142
|
+
self.setParent(main_window.centralWidget())
|
|
143
|
+
self.hide()
|
|
144
|
+
|
|
145
|
+
def _reposition(self) -> None:
|
|
146
|
+
if self._main_window is not None:
|
|
147
|
+
self.setGeometry(self._main_window.centralWidget().rect())
|
|
148
|
+
|
|
149
|
+
def show_overlay(self) -> None:
|
|
150
|
+
self._reposition()
|
|
151
|
+
self.raise_()
|
|
152
|
+
self.show()
|
|
153
|
+
|
|
154
|
+
def hide_overlay(self) -> None:
|
|
155
|
+
self.status_label.setText("")
|
|
156
|
+
self.reconnect_btn.setEnabled(True)
|
|
157
|
+
self.hide()
|
|
158
|
+
self.closed.emit()
|
|
159
|
+
|
|
160
|
+
def _on_reconnect_clicked(self) -> None:
|
|
161
|
+
server = self.worker.server
|
|
162
|
+
port = self.worker.port
|
|
163
|
+
if not server or not port:
|
|
164
|
+
self.reconnect_btn.setEnabled(True)
|
|
165
|
+
self.status_label.setText("No known server to reconnect to.")
|
|
166
|
+
return
|
|
167
|
+
self.reconnect_btn.setEnabled(False)
|
|
168
|
+
self.status_label.setStyleSheet("color: #d0d0d0;")
|
|
169
|
+
self.status_label.setText("Connecting…")
|
|
170
|
+
self.worker.connect_controller(
|
|
171
|
+
server,
|
|
172
|
+
port,
|
|
173
|
+
on_done=self._on_connect_ok,
|
|
174
|
+
on_error=self._on_connect_error,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def _on_connect_ok(self, controller) -> None:
|
|
178
|
+
self.hide_overlay()
|
|
179
|
+
|
|
180
|
+
def _on_connect_error(self, exc_str: str) -> None:
|
|
181
|
+
self.reconnect_btn.setEnabled(True)
|
|
182
|
+
self.status_label.setStyleSheet("color: #ffb4b4;")
|
|
183
|
+
self.status_label.setText(str(exc_str))
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Shared, per-program cache of the loaded-filter inventory (P1.3).
|
|
2
|
+
|
|
3
|
+
PF1: the Feedback Control Design probe and the Filters page both rebuilt the
|
|
4
|
+
same filter inventory (name / type / parameters, ~6 serialized target round
|
|
5
|
+
trips per filter) on every ``on_show``. This module reads it in one place and
|
|
6
|
+
caches the result per program so the second page open is free.
|
|
7
|
+
|
|
8
|
+
Cache contract
|
|
9
|
+
* Keyed on the controller's **program name** (``ctl.program.name``) and the
|
|
10
|
+
identity of the live ``ctl.filter._filters`` mapping. A new program
|
|
11
|
+
misses naturally; the filter-map identity guard keeps a reloaded/rebuilt
|
|
12
|
+
controller (and lightweight test doubles) from reusing a stale entry.
|
|
13
|
+
* :func:`invalidate` must be called after ANY write to a filter. The rtint
|
|
14
|
+
biquad coefficient setters silently rewrite ``filter_type`` to
|
|
15
|
+
``"custom"`` (plan Risk 1), so a cached type/display would otherwise go
|
|
16
|
+
stale; every write path in the pages drops the cache.
|
|
17
|
+
* The returned entries are plain dicts (copied shallowly) so a caller can
|
|
18
|
+
mutate its own view without corrupting the cache.
|
|
19
|
+
|
|
20
|
+
Entry shape::
|
|
21
|
+
|
|
22
|
+
{"name": str, "unique_name": str, "class": str,
|
|
23
|
+
"subtype": str | None, "params": dict}
|
|
24
|
+
|
|
25
|
+
``class`` is the filter class type (``"Biquad"`` / ``"PID"`` / ``"Remove DC"``
|
|
26
|
+
...), ``subtype`` the standard-filter preset when the library reports one, and
|
|
27
|
+
``params`` the derived continuous parameters from the SAME
|
|
28
|
+
``filter_parameters()`` call. The discrete ``b``/``a`` coefficients are NOT
|
|
29
|
+
read here: that would cost a second six-read pass per filter (the P0.6
|
|
30
|
+
``meta.loop`` capture in :mod:`rtint_gui.loop_meta` still reads them for saving).
|
|
31
|
+
|
|
32
|
+
Single-read contract
|
|
33
|
+
:func:`_read_entries` calls ``flt.filter_parameters()`` exactly ONCE per
|
|
34
|
+
filter and derives both the subtype and ``params`` from that result. For a
|
|
35
|
+
real rtint biquad ``filter_parameters()`` itself reads ``filter_type`` plus
|
|
36
|
+
``b0..a2`` (six round trips); reading the coefficients separately here used
|
|
37
|
+
to double the cost to twelve.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import inspect
|
|
43
|
+
import threading
|
|
44
|
+
|
|
45
|
+
_lock = threading.Lock()
|
|
46
|
+
#: Cached program name, filter-map object and entries. The strong reference to
|
|
47
|
+
#: the filter map is deliberate: it keeps ``id``/``is`` comparisons stable and
|
|
48
|
+
#: prevents a freed dict's identity from being reused by a later controller.
|
|
49
|
+
_cache_program: str | None = None
|
|
50
|
+
_cache_fmap: object | None = None
|
|
51
|
+
_cache_entries: list[dict] | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def invalidate() -> None:
|
|
55
|
+
"""Drop the cached inventory (call after any filter write / program change)."""
|
|
56
|
+
global _cache_program, _cache_fmap, _cache_entries
|
|
57
|
+
with _lock:
|
|
58
|
+
_cache_program = None
|
|
59
|
+
_cache_fmap = None
|
|
60
|
+
_cache_entries = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _program_of(ctl) -> str:
|
|
64
|
+
program = getattr(ctl, "program", None) if ctl is not None else None
|
|
65
|
+
name = getattr(program, "name", None)
|
|
66
|
+
return str(name) if name else ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fmap_of(ctl):
|
|
70
|
+
fctl = getattr(ctl, "filter", None) if ctl is not None else None
|
|
71
|
+
return getattr(fctl, "_filters", None) if fctl is not None else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _class_type(flt) -> str:
|
|
75
|
+
"""Filter CLASS type, mirroring the Filters page ``_filter_type_name``."""
|
|
76
|
+
value = getattr(flt, "_type", None)
|
|
77
|
+
if isinstance(value, str) and value:
|
|
78
|
+
return value
|
|
79
|
+
value = getattr(flt, "type", "")
|
|
80
|
+
return str(value) if value else ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _filter_parameters(flt) -> dict | None:
|
|
84
|
+
"""Guarded ``flt.filter_parameters()`` -> ``{"type", "params"}`` or ``None``.
|
|
85
|
+
|
|
86
|
+
This is the ONE target read per filter. For real rtint objects the call
|
|
87
|
+
itself reads ``filter_type`` + ``b0..a2`` (six round trips) and returns the
|
|
88
|
+
canonical subtype together with the derived continuous parameters; reading
|
|
89
|
+
either separately doubles the cost (the bug this replaces).
|
|
90
|
+
"""
|
|
91
|
+
fn = getattr(flt, "filter_parameters", None)
|
|
92
|
+
if not callable(fn):
|
|
93
|
+
return None
|
|
94
|
+
try:
|
|
95
|
+
info = fn()
|
|
96
|
+
except Exception: # noqa: BLE001 - an old/faulty library must not break us
|
|
97
|
+
return None
|
|
98
|
+
if not isinstance(info, dict):
|
|
99
|
+
return None
|
|
100
|
+
params = info.get("params")
|
|
101
|
+
if not isinstance(params, dict):
|
|
102
|
+
params = {}
|
|
103
|
+
return {"type": info.get("type"), "params": dict(params)}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_entries(fmap) -> list[dict]:
|
|
107
|
+
entries: list[dict] = []
|
|
108
|
+
for flt in (fmap or {}).values():
|
|
109
|
+
try:
|
|
110
|
+
name = getattr(flt, "name", "?")
|
|
111
|
+
unique = getattr(flt, "unique_name", "?")
|
|
112
|
+
class_type = _class_type(flt)
|
|
113
|
+
# Exactly ONE filter_parameters() call per filter: subtype AND
|
|
114
|
+
# params (coefficients) come from that single result.
|
|
115
|
+
info = _filter_parameters(flt)
|
|
116
|
+
except Exception: # noqa: BLE001 - one bad filter must not kill the list
|
|
117
|
+
continue
|
|
118
|
+
sub = (info or {}).get("type")
|
|
119
|
+
entries.append(
|
|
120
|
+
{
|
|
121
|
+
"name": name,
|
|
122
|
+
"unique_name": unique,
|
|
123
|
+
"class": class_type,
|
|
124
|
+
"subtype": sub if isinstance(sub, str) else None,
|
|
125
|
+
"params": (info or {}).get("params") or {},
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
return entries
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_filter_inventory(ctl, *, refresh: bool = False) -> list[dict]:
|
|
132
|
+
"""Return ``[{name, unique_name, class, subtype, b, a}, ...]`` for ``ctl``.
|
|
133
|
+
|
|
134
|
+
Cached per program + filter-map identity. ``refresh=True`` forces a
|
|
135
|
+
re-read (used by tests / explicit reloads); an empty ``ctl``/``filter``
|
|
136
|
+
yields ``[]`` and is cached like any other result.
|
|
137
|
+
"""
|
|
138
|
+
global _cache_program, _cache_fmap, _cache_entries
|
|
139
|
+
program = _program_of(ctl)
|
|
140
|
+
fmap = _fmap_of(ctl)
|
|
141
|
+
with _lock:
|
|
142
|
+
if (
|
|
143
|
+
not refresh
|
|
144
|
+
and _cache_entries is not None
|
|
145
|
+
and program == _cache_program
|
|
146
|
+
and fmap is _cache_fmap
|
|
147
|
+
):
|
|
148
|
+
return [dict(entry) for entry in _cache_entries]
|
|
149
|
+
entries = _read_entries(fmap)
|
|
150
|
+
with _lock:
|
|
151
|
+
_cache_program = program
|
|
152
|
+
_cache_fmap = fmap
|
|
153
|
+
_cache_entries = entries
|
|
154
|
+
return [dict(entry) for entry in entries]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --------------------------------------------------------------------------- #
|
|
158
|
+
# worker capability probe
|
|
159
|
+
# --------------------------------------------------------------------------- #
|
|
160
|
+
|
|
161
|
+
def worker_accepts(worker, kwarg: str) -> bool:
|
|
162
|
+
"""True when ``worker.submit`` advertises ``kwarg`` explicitly.
|
|
163
|
+
|
|
164
|
+
Lets the pages use the real worker's ``on_discard`` hook while staying
|
|
165
|
+
compatible with the lightweight synchronous test doubles whose ``submit``
|
|
166
|
+
only swallows unknown keywords into ``**kwargs`` (passing ``on_discard``
|
|
167
|
+
there would forward it to the job function and fail the job).
|
|
168
|
+
"""
|
|
169
|
+
submit = getattr(worker, "submit", None)
|
|
170
|
+
if not callable(submit):
|
|
171
|
+
return False
|
|
172
|
+
try:
|
|
173
|
+
return kwarg in inspect.signature(submit).parameters
|
|
174
|
+
except (TypeError, ValueError):
|
|
175
|
+
return False
|