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.
Files changed (63) hide show
  1. benchloom/__init__.py +43 -0
  2. benchloom/__main__.py +5 -0
  3. benchloom/cli.py +210 -0
  4. benchloom/config.py +88 -0
  5. benchloom/datalog.py +205 -0
  6. benchloom/engine.py +554 -0
  7. benchloom/graph.py +427 -0
  8. benchloom/gui/__init__.py +29 -0
  9. benchloom/gui/devices.py +529 -0
  10. benchloom/gui/editor.py +197 -0
  11. benchloom/gui/graph_editor.py +843 -0
  12. benchloom/gui/main_window.py +688 -0
  13. benchloom/gui/panel_view.py +448 -0
  14. benchloom/gui/plot.py +265 -0
  15. benchloom/gui/plugins.py +417 -0
  16. benchloom/gui/runner.py +132 -0
  17. benchloom/gui/settings.py +245 -0
  18. benchloom/gui/theme.py +75 -0
  19. benchloom/gui/waveforms.py +83 -0
  20. benchloom/modbus.py +33 -0
  21. benchloom/panel.py +198 -0
  22. benchloom/plugin.py +462 -0
  23. benchloom/plugin_install.py +144 -0
  24. benchloom/plugins/chroma/chroma6404.py +243 -0
  25. benchloom/plugins/chroma/plugin.toml +31 -0
  26. benchloom/plugins/demo/driver.py +27 -0
  27. benchloom/plugins/demo/plugin.toml +13 -0
  28. benchloom/plugins/demo/simulator.py +50 -0
  29. benchloom/plugins/feeltech/fy6900.py +661 -0
  30. benchloom/plugins/feeltech/plugin.toml +33 -0
  31. benchloom/plugins/generic/driver.py +39 -0
  32. benchloom/plugins/generic/plugin.toml +21 -0
  33. benchloom/plugins/generic/simulator.py +19 -0
  34. benchloom/plugins/hantek/dso5000p.py +617 -0
  35. benchloom/plugins/hantek/plugin.toml +18 -0
  36. benchloom/plugins/hantek/protocol.inf +125 -0
  37. benchloom/plugins/hp/hp3478a.py +260 -0
  38. benchloom/plugins/hp/hp53131a.py +294 -0
  39. benchloom/plugins/hp/hp6655a.py +155 -0
  40. benchloom/plugins/hp/plugin.toml +71 -0
  41. benchloom/plugins/kunkin/driver.py +197 -0
  42. benchloom/plugins/kunkin/plugin.toml +39 -0
  43. benchloom/plugins/kunkin/simulator.py +81 -0
  44. benchloom/project.py +246 -0
  45. benchloom/report.py +159 -0
  46. benchloom/scpi.py +155 -0
  47. benchloom/simbench.py +38 -0
  48. benchloom/svgplot.py +230 -0
  49. benchloom/svgpng.py +47 -0
  50. benchloom/templates/LICENSE.txt +21 -0
  51. benchloom/templates/bode/benchloom.toml +24 -0
  52. benchloom/templates/bode/main.lua +99 -0
  53. benchloom/templates/bode/panel.json +243 -0
  54. benchloom/templates/bode/template.toml +6 -0
  55. benchloom/transports.py +788 -0
  56. benchloom/worker.py +68 -0
  57. benchloom-0.1.3.dist-info/METADATA +422 -0
  58. benchloom-0.1.3.dist-info/RECORD +63 -0
  59. benchloom-0.1.3.dist-info/WHEEL +4 -0
  60. benchloom-0.1.3.dist-info/entry_points.txt +5 -0
  61. benchloom-0.1.3.dist-info/licenses/LICENSE +661 -0
  62. benchloom-0.1.3.dist-info/licenses/THIRD_PARTY_NOTICES.md +67 -0
  63. 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
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 Friedrich-Christer Rosenthal
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
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]