pyscopegui 0.1.0__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 (63) hide show
  1. pyscopegui/__init__.py +6 -0
  2. pyscopegui/__main__.py +216 -0
  3. pyscopegui/backends/__init__.py +67 -0
  4. pyscopegui/backends/base.py +195 -0
  5. pyscopegui/backends/demo.py +114 -0
  6. pyscopegui/backends/picoscope2000.py +1275 -0
  7. pyscopegui/backends/rigol1000z.py +744 -0
  8. pyscopegui/core/__init__.py +2 -0
  9. pyscopegui/core/acquire.py +274 -0
  10. pyscopegui/core/capture.py +216 -0
  11. pyscopegui/core/checkout.py +134 -0
  12. pyscopegui/core/context.py +53 -0
  13. pyscopegui/core/events.py +73 -0
  14. pyscopegui/core/excepthook.py +111 -0
  15. pyscopegui/core/installation.py +137 -0
  16. pyscopegui/core/layout.py +73 -0
  17. pyscopegui/core/packages.py +105 -0
  18. pyscopegui/core/paths.py +50 -0
  19. pyscopegui/core/session.py +113 -0
  20. pyscopegui/core/settings.py +44 -0
  21. pyscopegui/core/shortcut.py +154 -0
  22. pyscopegui/core/updates.py +195 -0
  23. pyscopegui/core/worker.py +66 -0
  24. pyscopegui/help/__init__.py +132 -0
  25. pyscopegui/help/about.md +47 -0
  26. pyscopegui/help/changelog.md +57 -0
  27. pyscopegui/help/event-log.md +31 -0
  28. pyscopegui/help/files.md +45 -0
  29. pyscopegui/help/instruments.md +134 -0
  30. pyscopegui/help/main-window.png +0 -0
  31. pyscopegui/help/manual.md +22 -0
  32. pyscopegui/help/panes.md +64 -0
  33. pyscopegui/help/picoscope.md +186 -0
  34. pyscopegui/help/plot.md +100 -0
  35. pyscopegui/help/rigol.md +92 -0
  36. pyscopegui/resources/__init__.py +10 -0
  37. pyscopegui/resources/pyscopegui.ico +0 -0
  38. pyscopegui/resources/pyscopegui.png +0 -0
  39. pyscopegui/resources/pyscopegui.svg +16 -0
  40. pyscopegui/resources/pyscopegui_small.svg +9 -0
  41. pyscopegui/ui/__init__.py +2 -0
  42. pyscopegui/ui/detached.py +85 -0
  43. pyscopegui/ui/event_colours.py +66 -0
  44. pyscopegui/ui/fonts.py +75 -0
  45. pyscopegui/ui/help_menu.py +620 -0
  46. pyscopegui/ui/instrument_bar.py +136 -0
  47. pyscopegui/ui/instrument_view.py +269 -0
  48. pyscopegui/ui/main_window.py +890 -0
  49. pyscopegui/ui/messages.py +78 -0
  50. pyscopegui/ui/pane_bar.py +92 -0
  51. pyscopegui/ui/panes.py +1010 -0
  52. pyscopegui/ui/persist.py +131 -0
  53. pyscopegui/ui/scope_view.py +622 -0
  54. pyscopegui/ui/spin.py +52 -0
  55. pyscopegui/ui/theme.py +85 -0
  56. pyscopegui/ui/trigger_bar.py +200 -0
  57. pyscopegui-0.1.0.dist-info/METADATA +120 -0
  58. pyscopegui-0.1.0.dist-info/RECORD +63 -0
  59. pyscopegui-0.1.0.dist-info/WHEEL +5 -0
  60. pyscopegui-0.1.0.dist-info/entry_points.txt +2 -0
  61. pyscopegui-0.1.0.dist-info/licenses/LICENSE +202 -0
  62. pyscopegui-0.1.0.dist-info/licenses/NOTICE +26 -0
  63. pyscopegui-0.1.0.dist-info/top_level.txt +1 -0
pyscopegui/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """pyscopegui — oscilloscopes and logic analysers in one view, on one time axis."""
4
+
5
+ __version__ = "0.1.0"
6
+ APP_NAME = "pyscopegui" # single place to change the display name
pyscopegui/__main__.py ADDED
@@ -0,0 +1,216 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """Entry point: `python -m pyscopegui`, or the `pyscopegui` command pip installs."""
4
+
5
+ import importlib
6
+ import os
7
+ import sys
8
+
9
+ from PySide6.QtWidgets import QApplication
10
+
11
+ from pyscopegui import APP_NAME
12
+
13
+ #: Imported by ``--selftest``. These are the modules a packaged build is most
14
+ #: likely to be missing, because they are loaded by name at run time and so no
15
+ #: static analysis can see them.
16
+ SELFTEST_MODULES = (
17
+ "pyqtgraph",
18
+ "numpy",
19
+ "pyscopegui.ui.main_window",
20
+ )
21
+
22
+
23
+ def selftest() -> int:
24
+ """Import everything a build could plausibly be missing. Exit code only:
25
+ a windowed build has nowhere to print to."""
26
+ failures = []
27
+ for name in SELFTEST_MODULES:
28
+ try:
29
+ importlib.import_module(name)
30
+ except Exception as exc:
31
+ failures.append(f"{name}: {exc}")
32
+ # Every back end's module, asked for the way the instrument list asks.
33
+ try:
34
+ from pyscopegui import backends
35
+
36
+ for name, backend in backends.BACKENDS.items():
37
+ try:
38
+ getattr(importlib.import_module(backend.module), backend.cls)
39
+ except Exception as exc:
40
+ failures.append(f"back end {name}: {exc}")
41
+ except Exception as exc:
42
+ failures.append(f"pyscopegui.backends: {exc}")
43
+
44
+ try:
45
+ from pyscopegui import resources
46
+
47
+ if not resources.path("pyscopegui.ico").is_file():
48
+ failures.append("pyscopegui/resources/pyscopegui.ico: not shipped with this build")
49
+ except Exception as exc:
50
+ failures.append(f"pyscopegui.resources: {exc}")
51
+
52
+ # The manual is package data, so it is exactly the kind of file a build
53
+ # drops silently. Every page, by name: a manual that still opens is the
54
+ # worst way for a missing page to be found.
55
+ try:
56
+ from pyscopegui.help import missing_images, missing_pages
57
+
58
+ for page in missing_pages():
59
+ failures.append(f"pyscopegui/help/{page}: not shipped with this build")
60
+ for image in missing_images():
61
+ failures.append(f"pyscopegui/help/{image}: shown in the manual but not shipped")
62
+ except Exception as exc:
63
+ failures.append(f"pyscopegui.help: {exc}")
64
+
65
+ # Help > Licences reads these at run time from beside the executable. A
66
+ # build that ships them where it cannot see them would show empty tabs.
67
+ try:
68
+ from pyscopegui.ui.help_menu import missing_licence_files
69
+
70
+ failures += [
71
+ f"{name}: not found beside the application"
72
+ for name in missing_licence_files(getattr(sys, "frozen", False))
73
+ ]
74
+ except Exception as exc:
75
+ failures.append(f"pyscopegui.ui.help_menu: {exc}")
76
+
77
+ if failures:
78
+ sys.stderr.write("selftest failures:" + "".join(f"\n {f}" for f in failures) + "\n")
79
+ return 1
80
+ return 0
81
+
82
+
83
+ #: Who Windows thinks the windows belong to. Without one, a pyscopegui started
84
+ #: from source is grouped under pythonw.exe and the taskbar shows Python's
85
+ #: icon whatever the window says -- the window icon only reaches the title bar.
86
+ APP_USER_MODEL_ID = "davhodg.pyscopegui"
87
+
88
+
89
+ def set_icon(app) -> None:
90
+ """The application icon, before any window exists.
91
+
92
+ The .ico rather than the PNG: it holds each size drawn for that size, and
93
+ Qt picks the nearest one.
94
+ """
95
+ from PySide6.QtGui import QIcon
96
+
97
+ from pyscopegui import resources
98
+
99
+ if sys.platform == "win32":
100
+ try:
101
+ import ctypes
102
+
103
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(APP_USER_MODEL_ID)
104
+ except (AttributeError, OSError): # an unusual shell: the title bar still has it
105
+ pass
106
+ app.setWindowIcon(QIcon(str(resources.path("pyscopegui.ico"))))
107
+
108
+
109
+ #: The libraries Qt's X11 plugin needs that Debian, Ubuntu and Linux Mint can
110
+ #: leave out, each with the package it comes in. Without one, Qt stops with
111
+ #: "could not load the Qt platform plugin xcb" and does not say which.
112
+ XCB_LIBRARIES = (
113
+ ("libxcb-cursor.so.0", "libxcb-cursor0"),
114
+ ("libxcb-icccm.so.4", "libxcb-icccm4"),
115
+ ("libxcb-image.so.0", "libxcb-image0"),
116
+ ("libxcb-keysyms.so.1", "libxcb-keysyms1"),
117
+ ("libxcb-render-util.so.0", "libxcb-render-util0"),
118
+ ("libxkbcommon-x11.so.0", "libxkbcommon-x11-0"),
119
+ )
120
+
121
+
122
+ def uses_xcb(environ=None) -> bool:
123
+ """Whether Qt will draw through X11 here, where XCB_LIBRARIES are needed."""
124
+ if not sys.platform.startswith("linux"):
125
+ return False
126
+ environ = os.environ if environ is None else environ
127
+ chosen = environ.get("QT_QPA_PLATFORM", "")
128
+ if chosen:
129
+ return chosen.startswith("xcb")
130
+ return environ.get("XDG_SESSION_TYPE") != "wayland"
131
+
132
+
133
+ def missing_xcb_packages(load=None) -> list[str]:
134
+ """The packages to install for the XCB_LIBRARIES that cannot be loaded."""
135
+ if load is None:
136
+ import ctypes
137
+
138
+ load = ctypes.CDLL
139
+ missing = []
140
+ for library, package in XCB_LIBRARIES:
141
+ try:
142
+ load(library)
143
+ except OSError:
144
+ missing.append(package)
145
+ return missing
146
+
147
+
148
+ def main() -> int:
149
+ if "--selftest" in sys.argv:
150
+ return selftest()
151
+ if uses_xcb() and (missing := missing_xcb_packages()):
152
+ sys.stderr.write(
153
+ "Qt needs libraries this system does not have to draw the window. On Debian,\n"
154
+ "Ubuntu and Linux Mint:\n\n"
155
+ f" sudo apt install {' '.join(missing)}\n"
156
+ )
157
+ return 1
158
+ app = QApplication.instance() or QApplication(sys.argv)
159
+ app.setApplicationName(APP_NAME)
160
+ app.setOrganizationName(APP_NAME) # QSettings uses these two for the registry/ini path
161
+ set_icon(app)
162
+ # Before anything is drawn, so the window opens in the colours chosen.
163
+ from pyscopegui.ui import theme
164
+
165
+ theme.apply(theme.chosen())
166
+
167
+ # Imported here rather than at the top of the file so that a library
168
+ # which is not installed becomes a dialog. Started from pyscopegui.cmd
169
+ # there is no console -- it runs pythonw -- so an ImportError on the way
170
+ # up is a window that never appears and not one word about why.
171
+ try:
172
+ from pyscopegui.ui.main_window import MainWindow
173
+ except Exception as exc:
174
+ # Not ImportError alone: a library that is installed and will not
175
+ # load fails however it likes.
176
+ from pyscopegui.ui import messages
177
+
178
+ messages.critical(None, f"{APP_NAME} cannot start", why_it_cannot_start(exc))
179
+ sys.stderr and sys.stderr.write(why_it_cannot_start(exc) + "\n")
180
+ return 1
181
+
182
+ window = MainWindow()
183
+ window.show()
184
+ return app.exec()
185
+
186
+
187
+ def why_it_cannot_start(failed: BaseException) -> str:
188
+ """What to tell somebody whose libraries would not load, and what to do.
189
+
190
+ Two different faults. A library that is not there is put right by
191
+ installing it. One that is there and will not load is usually two
192
+ libraries that no longer agree, and installing again changes nothing: it
193
+ wants the name of the file that failed, so that the pair can be found.
194
+ """
195
+ if isinstance(failed, ModuleNotFoundError):
196
+ return (
197
+ f"A library {APP_NAME} needs is missing:\n\n {failed}\n\n"
198
+ "Start it with pyscopegui.cmd (or pyscopegui.sh), which installs "
199
+ "anything missing before starting, or install it with pip."
200
+ )
201
+ import traceback
202
+
203
+ where = traceback.extract_tb(failed.__traceback__)
204
+ place = f"\n in {where[-1].filename}, line {where[-1].lineno}" if where else ""
205
+ return (
206
+ f"A library {APP_NAME} needs is installed but would not load:\n\n"
207
+ f" {type(failed).__name__}: {failed}{place}\n\n"
208
+ "This is usually two libraries that no longer agree, after a new release "
209
+ "of one of them. Upgrading the one named above, or going back a version "
210
+ "of the one it was loading, normally puts it right; in a source folder, "
211
+ "deleting .venv and starting the launcher installs them all again."
212
+ )
213
+
214
+
215
+ if __name__ == "__main__":
216
+ sys.exit(main())
@@ -0,0 +1,67 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """The instruments pyscopegui can read, by name.
4
+
5
+ Each is a module imported only when it is asked for, which is how python-can
6
+ keeps its adapters: a back end may need a maker's library that is not on this
7
+ machine, and one that cannot load must not take the others with it. The
8
+ maker's library is never bundled: it is installed separately.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ from typing import NamedTuple
15
+
16
+ from PySide6.QtCore import QObject
17
+
18
+ from pyscopegui.backends.base import Instrument, Setting
19
+
20
+
21
+ class Backend(NamedTuple):
22
+ module: str
23
+ cls: str
24
+ #: What the instrument list calls it.
25
+ title: str
26
+ #: A word for it, to tell its channels from another instrument's.
27
+ short: str
28
+
29
+
30
+ #: Name -> where it lives. The name is what is remembered between runs.
31
+ BACKENDS: dict[str, Backend] = {
32
+ "demo": Backend("pyscopegui.backends.demo", "DemoInstrument", "Demo signals", "Demo"),
33
+ "picoscope2000": Backend(
34
+ "pyscopegui.backends.picoscope2000", "PicoScope2000", "PicoScope 2204A (ps2000)", "Pico"
35
+ ),
36
+ "rigol1000z": Backend(
37
+ "pyscopegui.backends.rigol1000z", "Rigol1000Z", "Rigol DS1000Z series (network)", "Rigol"
38
+ ),
39
+ }
40
+
41
+ #: Chosen on a first run: something to look at with nothing plugged in.
42
+ DEFAULT = "demo"
43
+
44
+
45
+ def names() -> list[str]:
46
+ return list(BACKENDS)
47
+
48
+
49
+ def title(name: str) -> str:
50
+ return BACKENDS[name].title
51
+
52
+
53
+ def short(name: str) -> str:
54
+ return BACKENDS[name].short
55
+
56
+
57
+ def settings(name: str) -> tuple[Setting, ...]:
58
+ """What this instrument lets you set. Needs its module, not its library."""
59
+ backend = BACKENDS[name]
60
+ return getattr(importlib.import_module(backend.module), backend.cls).SETTINGS
61
+
62
+
63
+ def create(name: str, parent: QObject | None = None, options: dict | None = None) -> Instrument:
64
+ """Make the instrument called this. ImportError if its library is missing."""
65
+ backend = BACKENDS[name]
66
+ module = importlib.import_module(backend.module)
67
+ return getattr(module, backend.cls)(parent, options)
@@ -0,0 +1,195 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """What every instrument looks like to the rest of pyscopegui.
4
+
5
+ A scope, a logic analyser and a board of your own all come down to the same
6
+ thing: a set of channels, sampled evenly, arriving a block at a time. A back
7
+ end turns whatever its hardware speaks into that, and nothing above it knows
8
+ which one it is talking to.
9
+
10
+ Samples are counted rather than timestamped. A block says which sample it
11
+ starts at and the rate they were taken at, so the time of any sample is its
12
+ number over the rate -- exact, and free of the jitter in when a block
13
+ happened to reach the computer, which is milliseconds and would otherwise be
14
+ drawn as if the signal had it.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+
21
+ import numpy as np
22
+ from PySide6.QtCore import QObject, Signal
23
+
24
+ ANALOG = "analog"
25
+ DIGITAL = "digital"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Channel:
30
+ """One input of an instrument."""
31
+
32
+ #: What the instrument calls it, and what its samples arrive under.
33
+ key: str
34
+ name: str
35
+ kind: str = ANALOG
36
+ #: What an analog sample is measured in. A digital one is 0 or 1.
37
+ unit: str = "V"
38
+ #: The most an analog channel can read, either side of zero, as it is set
39
+ #: up now: 5.0 on a range of ±5 V. Zero where there is no such limit.
40
+ full_scale: float = 0.0
41
+ #: How it is set up, in a line, to be read beside its name: "1 V/div, 10x, DC".
42
+ detail: str = ""
43
+ #: The group its settings are listed under, so that choosing the channel
44
+ #: can show them: "Channel A". Empty where it has none.
45
+ group: str = ""
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Setting:
50
+ """One thing about an instrument that can be chosen: a range, a rate.
51
+
52
+ Described rather than drawn, so that a back end needs no widgets of its
53
+ own and every instrument's settings look alike. A setting with choices is
54
+ a list to pick from, one whose default is True or False is a tick box,
55
+ one whose default is text is a box to type in, and anything else is a
56
+ number.
57
+ """
58
+
59
+ #: What it is saved under, and what the instrument is handed it as.
60
+ key: str
61
+ label: str
62
+ default: object
63
+ #: (value, what the list calls it), for a setting picked from a list.
64
+ choices: tuple[tuple[object, str], ...] = ()
65
+ #: For a number: its limits, how many decimal places, and what it is in.
66
+ minimum: float = 0.0
67
+ maximum: float = 0.0
68
+ decimals: int = 0
69
+ unit: str = ""
70
+ #: How far its arrows move it. Zero is through 1, 2, 5, 10, 20, 50: for a
71
+ #: number that runs over many powers of ten, where no one step suits.
72
+ step: float = 0.0
73
+ #: The heading it is listed under. Headings that start "Channel " are
74
+ #: the same settings over again for each channel, and are shown one
75
+ #: channel at a time.
76
+ group: str = ""
77
+ #: Taken up as the instrument runs, with what is on the plot kept.
78
+ #: Anything else starts the run again, from an empty plot.
79
+ live: bool = False
80
+ #: (key, value) of another setting this one depends on: it has a say only
81
+ #: while that one is at that value, and is greyed out otherwise.
82
+ needs: tuple[str, object] | None = None
83
+ tip: str = ""
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class Block:
88
+ """A run of evenly spaced samples of every channel, all the same length."""
89
+
90
+ #: The number of its first sample, counted from when the instrument started.
91
+ first: int
92
+ #: Samples per second.
93
+ rate: float
94
+ #: Channel key -> samples: float32 in the channel's unit for an analog
95
+ #: channel, uint8 holding 0 or 1 for a digital one.
96
+ data: dict[str, np.ndarray]
97
+ #: The samples before this one were not taken, on purpose: the instrument
98
+ #: stopped reading for a moment to do something else. Drawn as a gap like
99
+ #: any other, and not reported as samples lost.
100
+ after_pause: bool = False
101
+ #: A capture complete in itself, taken by the instrument around its own
102
+ #: trigger, rather than the next stretch of a stream. It takes the place
103
+ #: of whatever was held; ``first`` is nought.
104
+ sweep: bool = False
105
+ #: In a sweep, the sample the instrument's trigger is at, or None if it
106
+ #: was set to take the sweep without one.
107
+ trigger: int | None = None
108
+ #: In a sweep, whether the signal crossed the level there, as opposed to
109
+ #: the instrument giving up waiting and taking the sweep anyway.
110
+ triggered: bool = False
111
+
112
+ def __len__(self) -> int:
113
+ return len(next(iter(self.data.values()), ()))
114
+
115
+
116
+ class Instrument(QObject):
117
+ """A source of samples. Subclass it, and add the class to ``BACKENDS``.
118
+
119
+ Made with a parent and its own settings: whatever settings.json holds
120
+ under ``instrument.<name>``, which may be nothing, or not a dictionary.
121
+ """
122
+
123
+ #: A ``Block``. Emitted on the GUI thread, or queued to it from another.
124
+ block = Signal(object)
125
+ #: Open, or not: connected to, and ready to be started.
126
+ opened_changed = Signal(bool)
127
+ #: Running, or not.
128
+ state_changed = Signal(bool)
129
+ #: Something went wrong that stopped it, in words for the Event Log.
130
+ error = Signal(str)
131
+ #: Something worth recording that is not a problem: which scope was found.
132
+ note = Signal(str)
133
+ #: It is still running, but what it is sending is not what was asked for.
134
+ warning = Signal(str)
135
+
136
+ #: What can be set, in the order it should be listed, before anything
137
+ #: is known about the instrument that is plugged in.
138
+ SETTINGS: tuple[Setting, ...] = ()
139
+
140
+ def settings_offered(self) -> tuple[Setting, ...]:
141
+ """What can be set on this one. Asked again once it is open, when a
142
+ back end may know more: which ranges this model has."""
143
+ return self.SETTINGS
144
+
145
+ @property
146
+ def sweeps(self) -> bool:
147
+ """Whether it sends sweeps, each complete in itself, rather than a stream."""
148
+ return False
149
+
150
+ def set_trigger(
151
+ self, source: str | None, level: float, rising: bool, position: float, auto: bool
152
+ ) -> None:
153
+ """What to trigger on, for an instrument that sends sweeps and so
154
+ triggers itself. ``auto`` is to take a sweep anyway when no trigger
155
+ comes. An instrument that streams is triggered by pyscopegui, and
156
+ need do nothing here."""
157
+
158
+ def set(self, key: str, value: object) -> None:
159
+ """Take up a setting. A ``live`` one may arrive while running; any
160
+ other arrives while stopped, and is used at the next start."""
161
+
162
+ @property
163
+ def is_open(self) -> bool:
164
+ raise NotImplementedError
165
+
166
+ def open(self) -> None:
167
+ """Connect to the instrument, which may take seconds and happen on a
168
+ thread: ``opened_changed`` says when it has, or that it could not."""
169
+ raise NotImplementedError
170
+
171
+ def close(self) -> None:
172
+ """Stop, and let go of the instrument, so that another program can have it."""
173
+ raise NotImplementedError
174
+
175
+ def channels(self) -> list[Channel]:
176
+ """The inputs it has, in the order they should be listed."""
177
+ raise NotImplementedError
178
+
179
+ @property
180
+ def rate(self) -> float:
181
+ """Samples per second, the same for every channel."""
182
+ raise NotImplementedError
183
+
184
+ @property
185
+ def running(self) -> bool:
186
+ raise NotImplementedError
187
+
188
+ def start(self) -> None:
189
+ """Begin sending blocks, counting samples from zero again. Opens the
190
+ instrument first if it is not open."""
191
+ raise NotImplementedError
192
+
193
+ def stop(self) -> None:
194
+ """Stop sending blocks. Stopping one that is not running is not an error."""
195
+ raise NotImplementedError
@@ -0,0 +1,114 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: 2026 davhodg
3
+ """An instrument that needs no hardware: two analog and four digital channels.
4
+
5
+ Something to look at on a first run, and what the tests read. Every sample is
6
+ a function of its number alone, so the same stretch of time always holds the
7
+ same data whichever blocks it happened to arrive in.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import time
13
+
14
+ import numpy as np
15
+ from PySide6.QtCore import QObject, QTimer
16
+
17
+ from pyscopegui.backends.base import DIGITAL, Block, Channel, Instrument
18
+
19
+ RATE = 100_000.0
20
+ #: How often a block is sent. Twenty a second is smooth to watch and far
21
+ #: fewer calls than Python would notice.
22
+ TICK_MS = 50
23
+ #: The most one block holds. A computer that was asleep, or a window dragged
24
+ #: for a second, would otherwise be answered with everything it missed at once.
25
+ MOST_S = 0.5
26
+
27
+ SINE_HZ = 50.0
28
+ RAMP_HZ = 7.0
29
+ #: Samples per half period of the fastest digital channel: 1 kHz at RATE.
30
+ #: Each channel after it is half the rate of the one before, as a counter's bits are.
31
+ CLOCK_HALF = 50
32
+
33
+ CHANNELS = [
34
+ Channel("A", "A: sine", full_scale=2.0),
35
+ Channel("B", "B: ramp", full_scale=0.5),
36
+ *(Channel(f"D{bit}", f"D{bit}", DIGITAL, "") for bit in range(4)),
37
+ ]
38
+
39
+
40
+ def samples(first: int, count: int) -> dict[str, np.ndarray]:
41
+ """What every channel reads for ``count`` samples starting at ``first``."""
42
+ n = np.arange(first, first + count, dtype=np.int64)
43
+ t = n / RATE
44
+ data = {
45
+ "A": (2.0 * np.sin(2 * np.pi * SINE_HZ * t)).astype(np.float32),
46
+ "B": ((t * RAMP_HZ) % 1.0 - 0.5).astype(np.float32),
47
+ }
48
+ counter = n // CLOCK_HALF
49
+ for bit in range(4):
50
+ data[f"D{bit}"] = ((counter >> bit) & 1).astype(np.uint8)
51
+ return data
52
+
53
+
54
+ class DemoInstrument(Instrument):
55
+ def __init__(self, parent: QObject | None = None, options: dict | None = None) -> None:
56
+ super().__init__(parent)
57
+ self._timer = QTimer(self, interval=TICK_MS, timeout=self._tick)
58
+ self._started = 0.0
59
+ self._sent = 0
60
+ self._is_open = False
61
+
62
+ def channels(self) -> list[Channel]:
63
+ return list(CHANNELS)
64
+
65
+ @property
66
+ def rate(self) -> float:
67
+ return RATE
68
+
69
+ @property
70
+ def running(self) -> bool:
71
+ return self._timer.isActive()
72
+
73
+ @property
74
+ def is_open(self) -> bool:
75
+ return self._is_open
76
+
77
+ def open(self) -> None:
78
+ if not self._is_open:
79
+ self._is_open = True
80
+ self.opened_changed.emit(True)
81
+
82
+ def close(self) -> None:
83
+ self.stop()
84
+ if self._is_open:
85
+ self._is_open = False
86
+ self.opened_changed.emit(False)
87
+
88
+ def start(self) -> None:
89
+ if self.running:
90
+ return
91
+ self.open()
92
+ self._started = time.perf_counter()
93
+ self._sent = 0
94
+ self._timer.start()
95
+ self.state_changed.emit(True)
96
+
97
+ def stop(self) -> None:
98
+ if not self.running:
99
+ return
100
+ self._timer.stop()
101
+ self.state_changed.emit(False)
102
+
103
+ def _tick(self) -> None:
104
+ """Send the samples that have fallen due since the last block."""
105
+ due = int((time.perf_counter() - self._started) * RATE) - self._sent
106
+ if due > MOST_S * RATE:
107
+ # Skipped rather than made up late: the sample count is the clock,
108
+ # so the gap is said as a gap and the capture keeps its time.
109
+ self._sent += due - int(MOST_S * RATE)
110
+ due = int(MOST_S * RATE)
111
+ if due <= 0:
112
+ return
113
+ self.block.emit(Block(self._sent, RATE, samples(self._sent, due)))
114
+ self._sent += due