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.
Files changed (74) hide show
  1. rtint_gui/__init__.py +8 -0
  2. rtint_gui/_version.py +24 -0
  3. rtint_gui/app.py +116 -0
  4. rtint_gui/connection.py +183 -0
  5. rtint_gui/filter_inventory.py +175 -0
  6. rtint_gui/loop_meta.py +366 -0
  7. rtint_gui/lutio.py +208 -0
  8. rtint_gui/main_window.py +454 -0
  9. rtint_gui/matio.py +1313 -0
  10. rtint_gui/pages/__init__.py +9 -0
  11. rtint_gui/pages/acquisition_engine.py +292 -0
  12. rtint_gui/pages/acquisition_page.py +420 -0
  13. rtint_gui/pages/base.py +263 -0
  14. rtint_gui/pages/closed_loop_analysis.py +429 -0
  15. rtint_gui/pages/counters_page.py +641 -0
  16. rtint_gui/pages/custom_page.py +432 -0
  17. rtint_gui/pages/dynamical_identification_page.py +3244 -0
  18. rtint_gui/pages/ethercat_page.py +377 -0
  19. rtint_gui/pages/feedback_analysis.py +304 -0
  20. rtint_gui/pages/feedback_mimo_analysis.py +689 -0
  21. rtint_gui/pages/feedback_mimo_page.py +2632 -0
  22. rtint_gui/pages/filters_page.py +2272 -0
  23. rtint_gui/pages/generators_page.py +3183 -0
  24. rtint_gui/pages/lut_page.py +1119 -0
  25. rtint_gui/pages/motors_page.py +2361 -0
  26. rtint_gui/pages/parameters_page.py +465 -0
  27. rtint_gui/pages/programs_page.py +778 -0
  28. rtint_gui/pages/regul_page.py +2031 -0
  29. rtint_gui/pages/signal_analysis_page.py +2913 -0
  30. rtint_gui/pages/tf_analysis_page.py +1918 -0
  31. rtint_gui/pages/triggers_page.py +281 -0
  32. rtint_gui/pages/utils_analysis.py +446 -0
  33. rtint_gui/styles.py +25 -0
  34. rtint_gui/widgets/__init__.py +0 -0
  35. rtint_gui/widgets/asd_editor.py +445 -0
  36. rtint_gui/widgets/axis_align.py +55 -0
  37. rtint_gui/widgets/bode_plot.py +546 -0
  38. rtint_gui/widgets/bode_tuning.py +1071 -0
  39. rtint_gui/widgets/characteristic_loci_plot.py +300 -0
  40. rtint_gui/widgets/color_choice.py +99 -0
  41. rtint_gui/widgets/comment_dialog.py +69 -0
  42. rtint_gui/widgets/counter_picker.py +258 -0
  43. rtint_gui/widgets/custom_dash.py +1333 -0
  44. rtint_gui/widgets/custom_ethercat_control_widget.py +334 -0
  45. rtint_gui/widgets/custom_ethercat_widget.py +287 -0
  46. rtint_gui/widgets/custom_generator_widget.py +526 -0
  47. rtint_gui/widgets/custom_lut_widget.py +523 -0
  48. rtint_gui/widgets/custom_rt_widget.py +931 -0
  49. rtint_gui/widgets/custom_script_button.py +380 -0
  50. rtint_gui/widgets/custom_signal_display.py +464 -0
  51. rtint_gui/widgets/custom_time_widget.py +928 -0
  52. rtint_gui/widgets/custom_widgets.py +1893 -0
  53. rtint_gui/widgets/entity_table.py +178 -0
  54. rtint_gui/widgets/filter_matrix_selector.py +208 -0
  55. rtint_gui/widgets/filter_param_form.py +200 -0
  56. rtint_gui/widgets/lazy_tree.py +299 -0
  57. rtint_gui/widgets/loop_diagram.py +450 -0
  58. rtint_gui/widgets/nyquist_plot.py +538 -0
  59. rtint_gui/widgets/parameter_picker.py +208 -0
  60. rtint_gui/widgets/plant_table.py +404 -0
  61. rtint_gui/widgets/plot_axis.py +170 -0
  62. rtint_gui/widgets/plot_colors.py +75 -0
  63. rtint_gui/widgets/plot_overlay.py +164 -0
  64. rtint_gui/widgets/plot_readout.py +304 -0
  65. rtint_gui/widgets/plot_view.py +65 -0
  66. rtint_gui/widgets/signal_group_picker.py +322 -0
  67. rtint_gui/widgets/tuning_host.py +136 -0
  68. rtint_gui/widgets/validated_line.py +286 -0
  69. rtint_gui/worker.py +444 -0
  70. rtint_gui-0.0.1.dist-info/METADATA +53 -0
  71. rtint_gui-0.0.1.dist-info/RECORD +74 -0
  72. rtint_gui-0.0.1.dist-info/WHEEL +5 -0
  73. rtint_gui-0.0.1.dist-info/entry_points.txt +2 -0
  74. 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())
@@ -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