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.
- pyscopegui/__init__.py +6 -0
- pyscopegui/__main__.py +216 -0
- pyscopegui/backends/__init__.py +67 -0
- pyscopegui/backends/base.py +195 -0
- pyscopegui/backends/demo.py +114 -0
- pyscopegui/backends/picoscope2000.py +1275 -0
- pyscopegui/backends/rigol1000z.py +744 -0
- pyscopegui/core/__init__.py +2 -0
- pyscopegui/core/acquire.py +274 -0
- pyscopegui/core/capture.py +216 -0
- pyscopegui/core/checkout.py +134 -0
- pyscopegui/core/context.py +53 -0
- pyscopegui/core/events.py +73 -0
- pyscopegui/core/excepthook.py +111 -0
- pyscopegui/core/installation.py +137 -0
- pyscopegui/core/layout.py +73 -0
- pyscopegui/core/packages.py +105 -0
- pyscopegui/core/paths.py +50 -0
- pyscopegui/core/session.py +113 -0
- pyscopegui/core/settings.py +44 -0
- pyscopegui/core/shortcut.py +154 -0
- pyscopegui/core/updates.py +195 -0
- pyscopegui/core/worker.py +66 -0
- pyscopegui/help/__init__.py +132 -0
- pyscopegui/help/about.md +47 -0
- pyscopegui/help/changelog.md +57 -0
- pyscopegui/help/event-log.md +31 -0
- pyscopegui/help/files.md +45 -0
- pyscopegui/help/instruments.md +134 -0
- pyscopegui/help/main-window.png +0 -0
- pyscopegui/help/manual.md +22 -0
- pyscopegui/help/panes.md +64 -0
- pyscopegui/help/picoscope.md +186 -0
- pyscopegui/help/plot.md +100 -0
- pyscopegui/help/rigol.md +92 -0
- pyscopegui/resources/__init__.py +10 -0
- pyscopegui/resources/pyscopegui.ico +0 -0
- pyscopegui/resources/pyscopegui.png +0 -0
- pyscopegui/resources/pyscopegui.svg +16 -0
- pyscopegui/resources/pyscopegui_small.svg +9 -0
- pyscopegui/ui/__init__.py +2 -0
- pyscopegui/ui/detached.py +85 -0
- pyscopegui/ui/event_colours.py +66 -0
- pyscopegui/ui/fonts.py +75 -0
- pyscopegui/ui/help_menu.py +620 -0
- pyscopegui/ui/instrument_bar.py +136 -0
- pyscopegui/ui/instrument_view.py +269 -0
- pyscopegui/ui/main_window.py +890 -0
- pyscopegui/ui/messages.py +78 -0
- pyscopegui/ui/pane_bar.py +92 -0
- pyscopegui/ui/panes.py +1010 -0
- pyscopegui/ui/persist.py +131 -0
- pyscopegui/ui/scope_view.py +622 -0
- pyscopegui/ui/spin.py +52 -0
- pyscopegui/ui/theme.py +85 -0
- pyscopegui/ui/trigger_bar.py +200 -0
- pyscopegui-0.1.0.dist-info/METADATA +120 -0
- pyscopegui-0.1.0.dist-info/RECORD +63 -0
- pyscopegui-0.1.0.dist-info/WHEEL +5 -0
- pyscopegui-0.1.0.dist-info/entry_points.txt +2 -0
- pyscopegui-0.1.0.dist-info/licenses/LICENSE +202 -0
- pyscopegui-0.1.0.dist-info/licenses/NOTICE +26 -0
- pyscopegui-0.1.0.dist-info/top_level.txt +1 -0
pyscopegui/__init__.py
ADDED
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
|