benchloom 0.1.3__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.
- benchloom/__init__.py +43 -0
- benchloom/__main__.py +5 -0
- benchloom/cli.py +210 -0
- benchloom/config.py +88 -0
- benchloom/datalog.py +205 -0
- benchloom/engine.py +554 -0
- benchloom/graph.py +427 -0
- benchloom/gui/__init__.py +29 -0
- benchloom/gui/devices.py +529 -0
- benchloom/gui/editor.py +197 -0
- benchloom/gui/graph_editor.py +843 -0
- benchloom/gui/main_window.py +688 -0
- benchloom/gui/panel_view.py +448 -0
- benchloom/gui/plot.py +265 -0
- benchloom/gui/plugins.py +417 -0
- benchloom/gui/runner.py +132 -0
- benchloom/gui/settings.py +245 -0
- benchloom/gui/theme.py +75 -0
- benchloom/gui/waveforms.py +83 -0
- benchloom/modbus.py +33 -0
- benchloom/panel.py +198 -0
- benchloom/plugin.py +462 -0
- benchloom/plugin_install.py +144 -0
- benchloom/plugins/chroma/chroma6404.py +243 -0
- benchloom/plugins/chroma/plugin.toml +31 -0
- benchloom/plugins/demo/driver.py +27 -0
- benchloom/plugins/demo/plugin.toml +13 -0
- benchloom/plugins/demo/simulator.py +50 -0
- benchloom/plugins/feeltech/fy6900.py +661 -0
- benchloom/plugins/feeltech/plugin.toml +33 -0
- benchloom/plugins/generic/driver.py +39 -0
- benchloom/plugins/generic/plugin.toml +21 -0
- benchloom/plugins/generic/simulator.py +19 -0
- benchloom/plugins/hantek/dso5000p.py +617 -0
- benchloom/plugins/hantek/plugin.toml +18 -0
- benchloom/plugins/hantek/protocol.inf +125 -0
- benchloom/plugins/hp/hp3478a.py +260 -0
- benchloom/plugins/hp/hp53131a.py +294 -0
- benchloom/plugins/hp/hp6655a.py +155 -0
- benchloom/plugins/hp/plugin.toml +71 -0
- benchloom/plugins/kunkin/driver.py +197 -0
- benchloom/plugins/kunkin/plugin.toml +39 -0
- benchloom/plugins/kunkin/simulator.py +81 -0
- benchloom/project.py +246 -0
- benchloom/report.py +159 -0
- benchloom/scpi.py +155 -0
- benchloom/simbench.py +38 -0
- benchloom/svgplot.py +230 -0
- benchloom/svgpng.py +47 -0
- benchloom/templates/LICENSE.txt +21 -0
- benchloom/templates/bode/benchloom.toml +24 -0
- benchloom/templates/bode/main.lua +99 -0
- benchloom/templates/bode/panel.json +243 -0
- benchloom/templates/bode/template.toml +6 -0
- benchloom/transports.py +788 -0
- benchloom/worker.py +68 -0
- benchloom-0.1.3.dist-info/METADATA +422 -0
- benchloom-0.1.3.dist-info/RECORD +63 -0
- benchloom-0.1.3.dist-info/WHEEL +4 -0
- benchloom-0.1.3.dist-info/entry_points.txt +5 -0
- benchloom-0.1.3.dist-info/licenses/LICENSE +661 -0
- benchloom-0.1.3.dist-info/licenses/THIRD_PARTY_NOTICES.md +67 -0
- benchloom-0.1.3.dist-info/licenses/src/benchloom/templates/LICENSE.txt +21 -0
benchloom/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Friedrich-Christer Rosenthal
|
|
3
|
+
"""Benchloom: open lab automation."""
|
|
4
|
+
|
|
5
|
+
from .plugin import CommandError, Device, Param, command
|
|
6
|
+
from .scpi import ScpiDevice
|
|
7
|
+
from .transports import RawSimTransport, SimTransport, Transport
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.3"
|
|
10
|
+
HOMEPAGE = "https://github.com/criscrafter/benchloom"
|
|
11
|
+
|
|
12
|
+
# The notices an interactive program under the AGPL shows: copyright, no warranty,
|
|
13
|
+
# the licence. The app's About dialog and `benchloom --version` print them.
|
|
14
|
+
NOTICE = f"""\
|
|
15
|
+
Benchloom {__version__}
|
|
16
|
+
Copyright (C) 2026 Friedrich-Christer Rosenthal
|
|
17
|
+
|
|
18
|
+
This program is free software: you can redistribute it and/or modify it under
|
|
19
|
+
the terms of the GNU Affero General Public License as published by the Free
|
|
20
|
+
Software Foundation, either version 3 of the License, or (at your option) any
|
|
21
|
+
later version.
|
|
22
|
+
|
|
23
|
+
This program is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
24
|
+
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
|
|
25
|
+
PARTICULAR PURPOSE. You are responsible for operating connected equipment
|
|
26
|
+
safely and for checking results. See the GNU Affero General Public License for
|
|
27
|
+
details.
|
|
28
|
+
|
|
29
|
+
Benchloom is developed with substantial assistance from AI tools. Every change
|
|
30
|
+
is reviewed by the maintainer and checked by automated tests.
|
|
31
|
+
|
|
32
|
+
Source code and licence: {HOMEPAGE}"""
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"CommandError",
|
|
36
|
+
"Device",
|
|
37
|
+
"Param",
|
|
38
|
+
"RawSimTransport",
|
|
39
|
+
"ScpiDevice",
|
|
40
|
+
"SimTransport",
|
|
41
|
+
"Transport",
|
|
42
|
+
"command",
|
|
43
|
+
]
|
benchloom/__main__.py
ADDED
benchloom/cli.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Friedrich-Christer Rosenthal
|
|
3
|
+
"""Command line: ``benchloom gui | new | run | devices | describe | plugins | usbgpib``."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import NOTICE
|
|
12
|
+
from .engine import run_project
|
|
13
|
+
from .plugin import PluginError, PluginRegistry
|
|
14
|
+
from .project import ProjectError, create_project, templates
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _new(args) -> int:
|
|
18
|
+
project = create_project(args.path, args.mode, args.template)
|
|
19
|
+
print(f"Created {project.mode} project in {project.path}")
|
|
20
|
+
print(f"Run it with: benchloom run {args.path}")
|
|
21
|
+
return 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _run(args) -> int:
|
|
25
|
+
result = run_project(args.path, trust_plugins=args.trust_plugins)
|
|
26
|
+
print(f"\n{len(result.rows)} row(s) logged. Run saved to {result.path}")
|
|
27
|
+
return 0 if result.status == "ok" else 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _devices(args) -> int:
|
|
31
|
+
# --project names the project on purpose, which is the permission to use its plugins
|
|
32
|
+
registry = PluginRegistry.default(Path(args.project) if args.project else None,
|
|
33
|
+
trust_project=True)
|
|
34
|
+
for plugin in registry.plugins.values():
|
|
35
|
+
print(f"{plugin.name} ({plugin.id} {plugin.version})")
|
|
36
|
+
for device in plugin.devices:
|
|
37
|
+
print(f" {device.id:<18} {device.name}")
|
|
38
|
+
for error in registry.errors:
|
|
39
|
+
print(f"warning: {error}", file=sys.stderr)
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _plugins(args) -> int:
|
|
44
|
+
from . import plugin_install
|
|
45
|
+
from .config import AppConfig
|
|
46
|
+
from .plugin import SOURCES
|
|
47
|
+
|
|
48
|
+
project = Path(args.project) if args.project else None
|
|
49
|
+
if args.action == "list":
|
|
50
|
+
registry = PluginRegistry.default(project, trust_project=True)
|
|
51
|
+
for info in registry.found:
|
|
52
|
+
status = registry.status(info)
|
|
53
|
+
mark = {"enabled": "on ", "disabled": "off", "replaced": "---"}[status]
|
|
54
|
+
print(f"{mark} {info.id:<12} {info.version:<8} "
|
|
55
|
+
f"{SOURCES.get(info.source, info.source):<14} {info.path}")
|
|
56
|
+
for folder, error in registry.broken:
|
|
57
|
+
print(f"err {folder}: {error}")
|
|
58
|
+
return 0
|
|
59
|
+
if not args.target:
|
|
60
|
+
raise PluginError(f"'plugins {args.action}' needs a {'path' if args.action == 'install' else 'plugin id'}")
|
|
61
|
+
if args.action == "install":
|
|
62
|
+
print(plugin_install.TRUST_WARNING)
|
|
63
|
+
info = plugin_install.install(Path(args.target), replace=args.replace)
|
|
64
|
+
print(f"Installed {info.name} {info.version} ({info.id}) in {info.path}")
|
|
65
|
+
return 0
|
|
66
|
+
registry = PluginRegistry.default(project, trust_project=True)
|
|
67
|
+
matches = [info for info in registry.found if info.id == args.target]
|
|
68
|
+
if not matches:
|
|
69
|
+
raise PluginError(f"no plugin with id '{args.target}' (see: benchloom plugins)")
|
|
70
|
+
if args.action == "remove":
|
|
71
|
+
installed = [info for info in matches if plugin_install.is_removable(info)]
|
|
72
|
+
if not installed:
|
|
73
|
+
raise PluginError(f"'{args.target}' is not an installed plugin")
|
|
74
|
+
plugin_install.remove(installed[0])
|
|
75
|
+
print(f"Removed {installed[0].path}")
|
|
76
|
+
return 0
|
|
77
|
+
config = AppConfig.load()
|
|
78
|
+
config.set_enabled(args.target, args.action == "enable")
|
|
79
|
+
config.save()
|
|
80
|
+
print(f"Plugin '{args.target}' switched {'on' if args.action == 'enable' else 'off'}")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _describe(args) -> int:
|
|
85
|
+
registry = PluginRegistry.default(Path(args.project) if args.project else None,
|
|
86
|
+
trust_project=True)
|
|
87
|
+
spec = registry.device(args.device)
|
|
88
|
+
print(f"{spec.name} ({spec.id})")
|
|
89
|
+
if spec.description:
|
|
90
|
+
print(spec.description)
|
|
91
|
+
print(f"Connections: {', '.join(spec.transports) or 'any'}\n")
|
|
92
|
+
for info in spec.load_driver().commands().values():
|
|
93
|
+
params = []
|
|
94
|
+
for p in info.params:
|
|
95
|
+
text = p.name
|
|
96
|
+
if p.unit:
|
|
97
|
+
text += f" [{p.unit}]"
|
|
98
|
+
if p.min is not None or p.max is not None:
|
|
99
|
+
low = "" if p.min is None else f"{p.min:g}"
|
|
100
|
+
high = "" if p.max is None else f"{p.max:g}"
|
|
101
|
+
text += f" {low}..{high}"
|
|
102
|
+
params.append(text if p.required else f"[{text}]")
|
|
103
|
+
returns = ""
|
|
104
|
+
if info.returns not in ("nil", "any"):
|
|
105
|
+
returns = f" -> {info.returns}" + (f" [{info.unit}]" if info.unit else "")
|
|
106
|
+
print(f" {info.name}({', '.join(params)}){returns}")
|
|
107
|
+
if info.doc:
|
|
108
|
+
print(f" {info.doc}")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _usbgpib(args) -> int:
|
|
113
|
+
from .transports import TransportError, find_usbtmc, open_transport
|
|
114
|
+
|
|
115
|
+
if not args.commands:
|
|
116
|
+
found = find_usbtmc(0x03EB, 0x2065)
|
|
117
|
+
for _, _, serial in found:
|
|
118
|
+
print(f"usbgpib://{serial}" if serial else "usbgpib:// (serial number not readable)")
|
|
119
|
+
if not found:
|
|
120
|
+
print("no UsbGpib adapter found (it only appears while a powered instrument "
|
|
121
|
+
"is connected to it)")
|
|
122
|
+
return 0
|
|
123
|
+
try:
|
|
124
|
+
transport = open_transport(f"usbgpib://{args.serial or ''}")
|
|
125
|
+
except TransportError as exc:
|
|
126
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
127
|
+
return 2
|
|
128
|
+
try:
|
|
129
|
+
for text in args.commands:
|
|
130
|
+
reply = transport.adapter_command(text)
|
|
131
|
+
print(f"{text} -> {reply}" if reply is not None else f"{text}: sent")
|
|
132
|
+
finally:
|
|
133
|
+
transport.close()
|
|
134
|
+
return 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _gui(args) -> int:
|
|
138
|
+
from .gui import main as gui_main
|
|
139
|
+
|
|
140
|
+
return gui_main(args.path)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv: list[str] | None = None) -> int:
|
|
144
|
+
parser = argparse.ArgumentParser(prog="benchloom", description=__doc__,
|
|
145
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
146
|
+
epilog="Benchloom comes with ABSOLUTELY NO WARRANTY; "
|
|
147
|
+
"see 'benchloom --version' for details.")
|
|
148
|
+
parser.add_argument("--version", action="version", version=NOTICE)
|
|
149
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
150
|
+
|
|
151
|
+
new = sub.add_parser("new", help="create a project with a working example")
|
|
152
|
+
new.add_argument("path")
|
|
153
|
+
new.add_argument("--mode", choices=("lua", "visual"), default="lua")
|
|
154
|
+
new.add_argument("--template", choices=[t.id for t in templates()], default="example",
|
|
155
|
+
help="what the project starts with: " + "; ".join(
|
|
156
|
+
f"{t.id} = {t.title}" for t in templates()))
|
|
157
|
+
new.set_defaults(func=_new)
|
|
158
|
+
|
|
159
|
+
run = sub.add_parser("run", help="run a project's test")
|
|
160
|
+
run.add_argument("path", nargs="?", default=".")
|
|
161
|
+
run.add_argument("--trust-plugins", action="store_true",
|
|
162
|
+
help="allow the project's own plugins folder (Python code) to load, "
|
|
163
|
+
"now and in later runs")
|
|
164
|
+
run.set_defaults(func=_run)
|
|
165
|
+
|
|
166
|
+
devices = sub.add_parser("devices", help="list the device types plugins provide")
|
|
167
|
+
devices.add_argument("--project", help="also include this project's plugins folder")
|
|
168
|
+
devices.set_defaults(func=_devices)
|
|
169
|
+
|
|
170
|
+
describe = sub.add_parser("describe", help="show the commands of a device type")
|
|
171
|
+
describe.add_argument("device", help="device type, e.g. demo.psu")
|
|
172
|
+
describe.add_argument("--project", help="also include this project's plugins folder")
|
|
173
|
+
describe.set_defaults(func=_describe)
|
|
174
|
+
|
|
175
|
+
plugins = sub.add_parser(
|
|
176
|
+
"plugins", help="list, install, remove, or switch plugins on and off",
|
|
177
|
+
description="list: every plugin found (on, off, or --- when replaced by a plugin "
|
|
178
|
+
"with the same id). install PATH: copy a plugin folder or .zip into "
|
|
179
|
+
"the user plugin folder. remove/enable/disable ID.")
|
|
180
|
+
plugins.add_argument("action", nargs="?", default="list",
|
|
181
|
+
choices=("list", "install", "remove", "enable", "disable"))
|
|
182
|
+
plugins.add_argument("target", nargs="?", help="a path for install, else a plugin id")
|
|
183
|
+
plugins.add_argument("--replace", action="store_true",
|
|
184
|
+
help="install over a plugin with the same id")
|
|
185
|
+
plugins.add_argument("--project", help="also include this project's plugins folder")
|
|
186
|
+
plugins.set_defaults(func=_plugins)
|
|
187
|
+
|
|
188
|
+
usbgpib = sub.add_parser(
|
|
189
|
+
"usbgpib", help="list xyphro UsbGpib adapters, or send them their own '!' commands",
|
|
190
|
+
description="Without commands: list the adapters. With commands: send them to the "
|
|
191
|
+
"adapter, e.g. benchloom usbgpib \"!autoid off\" for instruments that "
|
|
192
|
+
"do not understand *IDN? (HP 3478A), or \"!ver?\".")
|
|
193
|
+
usbgpib.add_argument("commands", nargs="*")
|
|
194
|
+
usbgpib.add_argument("--serial", help="the adapter's serial number, if there are several")
|
|
195
|
+
usbgpib.set_defaults(func=_usbgpib)
|
|
196
|
+
|
|
197
|
+
gui = sub.add_parser("gui", help="open the desktop app")
|
|
198
|
+
gui.add_argument("path", nargs="?", help="project folder to open")
|
|
199
|
+
gui.set_defaults(func=_gui)
|
|
200
|
+
|
|
201
|
+
args = parser.parse_args(argv)
|
|
202
|
+
try:
|
|
203
|
+
return args.func(args)
|
|
204
|
+
except (ProjectError, PluginError) as exc:
|
|
205
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
if __name__ == "__main__":
|
|
210
|
+
raise SystemExit(main())
|
benchloom/config.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Friedrich-Christer Rosenthal
|
|
3
|
+
"""App-wide settings shared by the desktop app, the command line and test runs.
|
|
4
|
+
|
|
5
|
+
Stored as JSON in ``~/.benchloom/config.json`` (or ``$BENCHLOOM_HOME/config.json``).
|
|
6
|
+
Before the rename to Benchloom this was ``~/.patchbay``; that folder is still
|
|
7
|
+
used while there is no ``~/.benchloom``.
|
|
8
|
+
Only settings that a test run needs live here, such as which plugins are
|
|
9
|
+
switched off; window layout and other desktop-only choices stay in Qt's own
|
|
10
|
+
settings store.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
CONFIG_NAME = "config.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def home_dir() -> Path:
|
|
24
|
+
"""Benchloom's own folder: user plugins and config.json."""
|
|
25
|
+
override = os.environ.get("BENCHLOOM_HOME") or os.environ.get("PATCHBAY_HOME")
|
|
26
|
+
if override:
|
|
27
|
+
return Path(override)
|
|
28
|
+
home = Path.home() / ".benchloom"
|
|
29
|
+
legacy = Path.home() / ".patchbay"
|
|
30
|
+
return legacy if legacy.is_dir() and not home.exists() else home
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def user_plugin_dir() -> Path:
|
|
34
|
+
return home_dir() / "plugins"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _project_key(project_dir: str | Path) -> str:
|
|
38
|
+
"""A project folder as it is compared and stored: absolute, in the system's spelling."""
|
|
39
|
+
return os.path.normcase(str(Path(project_dir).resolve()))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class AppConfig:
|
|
44
|
+
disabled_plugins: list[str] = field(default_factory=list)
|
|
45
|
+
plugin_paths: list[str] = field(default_factory=list) # extra folders to load plugins from
|
|
46
|
+
# project folders whose own plugins/ folder may be loaded (the user said yes)
|
|
47
|
+
trusted_projects: list[str] = field(default_factory=list)
|
|
48
|
+
_other: dict = field(default_factory=dict, repr=False) # keys from newer versions, kept
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def load(cls) -> "AppConfig":
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads((home_dir() / CONFIG_NAME).read_text(encoding="utf-8"))
|
|
54
|
+
except (OSError, ValueError):
|
|
55
|
+
data = {}
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
data = {}
|
|
58
|
+
|
|
59
|
+
def strings(key):
|
|
60
|
+
value = data.pop(key, [])
|
|
61
|
+
return [str(v) for v in value] if isinstance(value, list) else []
|
|
62
|
+
|
|
63
|
+
return cls(strings("disabled_plugins"), strings("plugin_paths"),
|
|
64
|
+
strings("trusted_projects"), data)
|
|
65
|
+
|
|
66
|
+
def save(self) -> None:
|
|
67
|
+
path = home_dir() / CONFIG_NAME
|
|
68
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
data = dict(self._other, disabled_plugins=sorted(set(self.disabled_plugins)),
|
|
70
|
+
plugin_paths=self.plugin_paths,
|
|
71
|
+
trusted_projects=sorted(set(self.trusted_projects)))
|
|
72
|
+
tmp = path.with_suffix(".tmp")
|
|
73
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
74
|
+
tmp.replace(path)
|
|
75
|
+
|
|
76
|
+
def trusts(self, project_dir: str | Path) -> bool:
|
|
77
|
+
"""Whether the user allowed this project's own plugins to be loaded."""
|
|
78
|
+
return _project_key(project_dir) in {_project_key(p) for p in self.trusted_projects}
|
|
79
|
+
|
|
80
|
+
def set_trusted(self, project_dir: str | Path, trusted: bool = True) -> None:
|
|
81
|
+
key = _project_key(project_dir)
|
|
82
|
+
kept = [p for p in self.trusted_projects if _project_key(p) != key]
|
|
83
|
+
self.trusted_projects = sorted(kept + [key] if trusted else kept)
|
|
84
|
+
|
|
85
|
+
def set_enabled(self, plugin_id: str, enabled: bool) -> None:
|
|
86
|
+
disabled = set(self.disabled_plugins)
|
|
87
|
+
(disabled.discard if enabled else disabled.add)(plugin_id)
|
|
88
|
+
self.disabled_plugins = sorted(disabled)
|
benchloom/datalog.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 Friedrich-Christer Rosenthal
|
|
3
|
+
"""Run folders: every run keeps its data, console output and script together.
|
|
4
|
+
|
|
5
|
+
A run folder holds ``data.csv`` (the logged rows), ``console.log``, ``run.json``
|
|
6
|
+
(how the run went), a copy of the script, ``checks.csv`` (pass/fail checks, when
|
|
7
|
+
the test made any), ``waveforms/`` (saved scope traces as CSV, SVG and PNG) and
|
|
8
|
+
``report.html``, which shows all of it on one page.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
import importlib.util
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import asdict, dataclass
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Sequence
|
|
25
|
+
|
|
26
|
+
from .svgplot import waveform_svg
|
|
27
|
+
|
|
28
|
+
# The packaged app's programs start the PNG helper when given this first argument.
|
|
29
|
+
SVGPNG_ARG = "--benchloom-svgpng"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Check:
|
|
34
|
+
name: str
|
|
35
|
+
value: Any
|
|
36
|
+
low: float | None
|
|
37
|
+
high: float | None
|
|
38
|
+
unit: str
|
|
39
|
+
passed: bool
|
|
40
|
+
time_s: float
|
|
41
|
+
|
|
42
|
+
def limits(self) -> str:
|
|
43
|
+
if self.low is not None and self.high is not None:
|
|
44
|
+
return f"{self.low:g} to {self.high:g}"
|
|
45
|
+
if self.low is not None:
|
|
46
|
+
return f"at least {self.low:g}"
|
|
47
|
+
if self.high is not None:
|
|
48
|
+
return f"at most {self.high:g}"
|
|
49
|
+
return "true"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Waveform:
|
|
54
|
+
name: str
|
|
55
|
+
file: str # path relative to the run folder, without extension
|
|
56
|
+
traces: dict[str, tuple[list, list]]
|
|
57
|
+
time_s: float
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def evaluate(value: Any, low: float | None, high: float | None) -> bool:
|
|
61
|
+
"""Whether a check passes: a number within its limits, or true without limits."""
|
|
62
|
+
if low is None and high is None:
|
|
63
|
+
return value is True or (isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
64
|
+
and math.isfinite(value) and value != 0)
|
|
65
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or math.isnan(value):
|
|
66
|
+
return False
|
|
67
|
+
return (low is None or value >= low) and (high is None or value <= high)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RunLog:
|
|
71
|
+
def __init__(self, runs_dir: Path):
|
|
72
|
+
self.started = datetime.now()
|
|
73
|
+
self._t0 = time.monotonic()
|
|
74
|
+
stamp = self.started.strftime("%Y-%m-%d_%H%M%S")
|
|
75
|
+
self.path = runs_dir / stamp
|
|
76
|
+
n = 2
|
|
77
|
+
while self.path.exists():
|
|
78
|
+
self.path = runs_dir / f"{stamp}_{n}"
|
|
79
|
+
n += 1
|
|
80
|
+
self.path.mkdir(parents=True)
|
|
81
|
+
self.rows: list[dict[str, Any]] = []
|
|
82
|
+
self.checks: list[Check] = []
|
|
83
|
+
self.waveforms: list[Waveform] = []
|
|
84
|
+
self._columns = ["time_s"]
|
|
85
|
+
self._console = open(self.path / "console.log", "w", encoding="utf-8")
|
|
86
|
+
|
|
87
|
+
def elapsed(self) -> float:
|
|
88
|
+
return time.monotonic() - self._t0
|
|
89
|
+
|
|
90
|
+
def console(self, text: str) -> None:
|
|
91
|
+
self._console.write(text + "\n")
|
|
92
|
+
self._console.flush()
|
|
93
|
+
|
|
94
|
+
def log(self, values: dict[str, Any]) -> dict[str, Any]:
|
|
95
|
+
"""Append one row to data.csv; new keys become new columns. Returns the row."""
|
|
96
|
+
row = {"time_s": round(self.elapsed(), 3), **values}
|
|
97
|
+
self.rows.append(row)
|
|
98
|
+
new = [key for key in row if key not in self._columns]
|
|
99
|
+
file = self.path / "data.csv"
|
|
100
|
+
if new or len(self.rows) == 1:
|
|
101
|
+
self._columns += sorted(new)
|
|
102
|
+
with open(file, "w", newline="", encoding="utf-8") as f:
|
|
103
|
+
writer = csv.DictWriter(f, self._columns, restval="")
|
|
104
|
+
writer.writeheader()
|
|
105
|
+
writer.writerows(self.rows)
|
|
106
|
+
else:
|
|
107
|
+
with open(file, "a", newline="", encoding="utf-8") as f:
|
|
108
|
+
csv.DictWriter(f, self._columns, restval="").writerow(row)
|
|
109
|
+
return row
|
|
110
|
+
|
|
111
|
+
# --- checks -------------------------------------------------------------
|
|
112
|
+
def check(self, name: str, value: Any, low: float | None = None, high: float | None = None,
|
|
113
|
+
unit: str = "") -> Check:
|
|
114
|
+
"""Record a pass/fail check and append it to checks.csv."""
|
|
115
|
+
result = Check(str(name), value, low, high, unit or "", evaluate(value, low, high),
|
|
116
|
+
round(self.elapsed(), 3))
|
|
117
|
+
self.checks.append(result)
|
|
118
|
+
file = self.path / "checks.csv"
|
|
119
|
+
new = not file.exists()
|
|
120
|
+
with open(file, "a", newline="", encoding="utf-8") as f:
|
|
121
|
+
writer = csv.writer(f)
|
|
122
|
+
if new:
|
|
123
|
+
writer.writerow(["time_s", "name", "value", "low", "high", "unit", "result"])
|
|
124
|
+
writer.writerow([result.time_s, result.name, result.value,
|
|
125
|
+
"" if low is None else low, "" if high is None else high,
|
|
126
|
+
result.unit, "pass" if result.passed else "fail"])
|
|
127
|
+
return result
|
|
128
|
+
|
|
129
|
+
def verdict(self) -> str:
|
|
130
|
+
"""'pass', 'fail', or 'none' when the test made no checks."""
|
|
131
|
+
if not self.checks:
|
|
132
|
+
return "none"
|
|
133
|
+
return "pass" if all(c.passed for c in self.checks) else "fail"
|
|
134
|
+
|
|
135
|
+
# --- waveforms ------------------------------------------------------------
|
|
136
|
+
def save_waveform(self, name: str, traces: dict[str, tuple[Sequence, Sequence]]) -> Waveform:
|
|
137
|
+
"""Save scope traces (name -> (times, volts)) as CSV and SVG under waveforms/."""
|
|
138
|
+
folder = self.path / "waveforms"
|
|
139
|
+
folder.mkdir(exist_ok=True)
|
|
140
|
+
slug = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(name)).strip("_") or "waveform"
|
|
141
|
+
stem = f"{len(self.waveforms) + 1:03d}_{slug}"[:80]
|
|
142
|
+
traces = {str(k): (list(t), list(v)) for k, (t, v) in traces.items()}
|
|
143
|
+
names = list(traces)
|
|
144
|
+
shared = len({tuple(t) for t, _ in traces.values()}) == 1
|
|
145
|
+
with open(folder / f"{stem}.csv", "w", newline="", encoding="utf-8") as f:
|
|
146
|
+
writer = csv.writer(f)
|
|
147
|
+
if shared:
|
|
148
|
+
times = traces[names[0]][0]
|
|
149
|
+
writer.writerow(["time_s"] + [f"{n}_V" for n in names])
|
|
150
|
+
writer.writerows([t] + [traces[n][1][i] for n in names]
|
|
151
|
+
for i, t in enumerate(times))
|
|
152
|
+
else:
|
|
153
|
+
writer.writerow([c for n in names for c in (f"{n}_time_s", f"{n}_V")])
|
|
154
|
+
longest = max(len(t) for t, _ in traces.values())
|
|
155
|
+
for i in range(longest):
|
|
156
|
+
writer.writerow([x for n in names for x in (
|
|
157
|
+
(traces[n][0][i], traces[n][1][i]) if i < len(traces[n][0]) else ("", ""))])
|
|
158
|
+
(folder / f"{stem}.svg").write_text(waveform_svg(traces, str(name)), encoding="utf-8")
|
|
159
|
+
waveform = Waveform(str(name), f"waveforms/{stem}", traces, round(self.elapsed(), 3))
|
|
160
|
+
self.waveforms.append(waveform)
|
|
161
|
+
return waveform
|
|
162
|
+
|
|
163
|
+
def _render_pngs(self) -> None:
|
|
164
|
+
"""PNG copies of the waveform images, drawn by Qt in a helper process, when the
|
|
165
|
+
desktop app is installed. The run itself never loads Qt."""
|
|
166
|
+
frozen = getattr(sys, "frozen", False) # the packaged app: no python -m there
|
|
167
|
+
if not self.waveforms or (not frozen and importlib.util.find_spec("PySide6") is None):
|
|
168
|
+
return
|
|
169
|
+
files = [str(self.path / f"{w.file}.svg") for w in self.waveforms]
|
|
170
|
+
helper = [sys.executable, SVGPNG_ARG] if frozen else [sys.executable, "-m",
|
|
171
|
+
"benchloom.svgpng"]
|
|
172
|
+
try:
|
|
173
|
+
subprocess.run(helper + files, timeout=60 + 2 * len(files), capture_output=True,
|
|
174
|
+
check=False, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
175
|
+
except (OSError, subprocess.SubprocessError):
|
|
176
|
+
pass # the SVG images stay
|
|
177
|
+
|
|
178
|
+
# --- end of the run -------------------------------------------------------
|
|
179
|
+
def finish(self, status: str, error: str | None, info: dict[str, Any],
|
|
180
|
+
plot: dict[str, Any] | None = None) -> None:
|
|
181
|
+
self._console.close()
|
|
182
|
+
passed = sum(c.passed for c in self.checks)
|
|
183
|
+
meta = {
|
|
184
|
+
**info,
|
|
185
|
+
"started": self.started.isoformat(timespec="seconds"),
|
|
186
|
+
"duration_s": round(self.elapsed(), 3),
|
|
187
|
+
"status": status,
|
|
188
|
+
"error": error,
|
|
189
|
+
"rows": len(self.rows),
|
|
190
|
+
"result": self.verdict(),
|
|
191
|
+
"checks": {"passed": passed, "failed": len(self.checks) - passed},
|
|
192
|
+
"waveforms": [w.file for w in self.waveforms],
|
|
193
|
+
}
|
|
194
|
+
(self.path / "run.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
195
|
+
self._render_pngs()
|
|
196
|
+
from .report import write_report
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
write_report(self, meta, plot or {})
|
|
200
|
+
except Exception as exc: # a report must never turn a finished run into a crash
|
|
201
|
+
(self.path / "report.html").write_text(
|
|
202
|
+
f"<p>The report could not be made: {exc!r}</p>", encoding="utf-8")
|
|
203
|
+
|
|
204
|
+
def check_records(self) -> list[dict[str, Any]]:
|
|
205
|
+
return [asdict(c) for c in self.checks]
|