oasis-daq 0.0.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.
oasis_daq/__init__.py ADDED
@@ -0,0 +1,28 @@
1
+ """Python DAQ for the Open Acquisition System for IEPE Sensors (OASIS).
2
+
3
+ Version and description come from the installed distribution metadata, which
4
+ setuptools builds from pyproject.toml, so what the About dialog shows is what
5
+ is actually installed. In a source checkout that was never installed there is
6
+ no metadata to read and the fallbacks below apply.
7
+ """
8
+
9
+ from importlib.metadata import PackageNotFoundError
10
+ from importlib.metadata import metadata as _metadata
11
+ from importlib.metadata import version as _version
12
+
13
+ __all__ = ["APP_NAME", "DESCRIPTION", "DISTRIBUTION", "__version__"]
14
+
15
+ DISTRIBUTION = "oasis-daq" # the project name in pyproject.toml
16
+ APP_NAME = "OASIS-DAQ" # the same name as it is shown to the user
17
+
18
+ _UNKNOWN_VERSION = "unknown"
19
+ _FALLBACK_DESCRIPTION = (
20
+ "Python DAQ for the Open Acquisition System for IEPE Sensors (OASIS)"
21
+ )
22
+
23
+ try:
24
+ __version__ = _version(DISTRIBUTION)
25
+ DESCRIPTION = _metadata(DISTRIBUTION)["Summary"] or _FALLBACK_DESCRIPTION
26
+ except PackageNotFoundError:
27
+ __version__ = _UNKNOWN_VERSION
28
+ DESCRIPTION = _FALLBACK_DESCRIPTION
oasis_daq/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,410 @@
1
+ """Triggered multi-board acquisition loop for impact testing.
2
+
3
+ Multi-board handling follows oasis-gui: all boards share the CONVST line, the
4
+ primary board broadcasts the sampling parameters and every board stores the
5
+ same capture on its own SD card. Acquisition therefore has two steps here:
6
+ the primary board is armed and the impact is recorded (`_sample`), then the
7
+ capture is pulled from every board's USB MSC volume - all boards at the same
8
+ time, the primary included, since the volumes are independent - and the
9
+ boards' channels are stacked in port order (`stack_sources`).
10
+
11
+ Splitting the two steps means the sampling command is sent from here instead
12
+ of through ``OasisBoard.acquire()``, which always downloads the primary's own
13
+ capture before returning.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import tempfile
19
+ import threading
20
+ from concurrent.futures import ThreadPoolExecutor, as_completed
21
+ from datetime import datetime
22
+
23
+ import numpy as np
24
+ from oasis_api import OasisBoard
25
+ from PyQt6.QtCore import QThread, pyqtSignal
26
+
27
+ DEFAULT_SERIAL_BAUDRATE = 1_000_000
28
+ PULL_ATTEMPTS = 2 # a second pull after rebooting the boards that failed
29
+
30
+
31
+ class PullError(RuntimeError):
32
+ """One or more boards did not hand over their capture; `ports` lists them."""
33
+
34
+ def __init__(self, failures: dict[str, str]) -> None:
35
+ self.failures = dict(failures)
36
+ self.ports = list(failures)
37
+ super().__init__(
38
+ "Transfer failed: "
39
+ + " | ".join(f"{port}: {error}" for port, error in self.failures.items())
40
+ )
41
+
42
+
43
+ class AcquisitionWorker(QThread):
44
+ """Repeatedly arms the boards and emits one record per triggered impact."""
45
+
46
+ stateChanged = pyqtSignal(str, str) # state key, human readable detail
47
+ log = pyqtSignal(str)
48
+ progress = pyqtSignal(int)
49
+ recordReady = pyqtSignal(object) # dict: data [C, S], t [S], sample_rate
50
+ finished_ok = pyqtSignal()
51
+ failed = pyqtSignal(str)
52
+
53
+ def __init__(
54
+ self,
55
+ ports: list[str],
56
+ voltage_ranges: list[list[float]], # one 8-list per board
57
+ sample_rate: int,
58
+ duration: float,
59
+ oversampling_exp: int,
60
+ trigger_level: float,
61
+ continuous: bool = True,
62
+ baudrate: int = DEFAULT_SERIAL_BAUDRATE,
63
+ parent=None,
64
+ ) -> None:
65
+ super().__init__(parent)
66
+ self.ports = list(ports)
67
+ self.voltage_ranges = [list(ranges) for ranges in voltage_ranges]
68
+ self.sample_rate = int(sample_rate)
69
+ self.duration = float(duration)
70
+ self.oversampling_exp = int(oversampling_exp)
71
+ self.trigger_level = float(trigger_level)
72
+ self.continuous = bool(continuous)
73
+ self.baudrate = int(baudrate)
74
+ self._stop_requested = False
75
+ self._board: OasisBoard | None = None
76
+ self._board_lock = threading.Lock()
77
+ self._pull_percent: dict[str, int] = {} # per-board transfer progress
78
+
79
+ # --- control ------------------------------------------------------------
80
+ def request_stop(self) -> None:
81
+ self._stop_requested = True
82
+ with self._board_lock:
83
+ board = self._board
84
+ if board is not None:
85
+ try:
86
+ board.abort()
87
+ except Exception:
88
+ pass
89
+
90
+ # --- worker -------------------------------------------------------------
91
+ def run(self) -> None:
92
+ try:
93
+ self._configure_secondary_boards()
94
+ except Exception as exc:
95
+ self.failed.emit(f"Configuring additional boards failed: {exc}")
96
+ return
97
+
98
+ while not self._stop_requested:
99
+ try:
100
+ payload = self._acquire_once()
101
+ except Exception as exc:
102
+ if self._stop_requested:
103
+ break
104
+ self.failed.emit(str(exc))
105
+ return
106
+ if payload is None:
107
+ break
108
+ self.recordReady.emit(payload)
109
+ if not self.continuous:
110
+ break
111
+ self.finished_ok.emit()
112
+
113
+ def _configure_secondary_boards(self) -> None:
114
+ """Push voltage ranges and oversampling to the non-primary boards."""
115
+ for port, ranges in zip(self.ports[1:], self.voltage_ranges[1:]):
116
+ if self._stop_requested: # arming can take seconds per board
117
+ return
118
+ self.stateChanged.emit("arming", f"Configuring board on {port}...")
119
+ board = OasisBoard(mode="serial", port=port, baudrate=self.baudrate)
120
+ try:
121
+ board.connect()
122
+ board.set_parameters(
123
+ t_sample=self.duration,
124
+ f_sample=self.sample_rate,
125
+ voltage_range=ranges,
126
+ trigger=self.trigger_level > 0,
127
+ v_trigg=self.trigger_level,
128
+ oversampling=self.oversampling_exp,
129
+ sync_mode=0,
130
+ )
131
+ board.set_oversampling_and_range_only()
132
+ finally:
133
+ try:
134
+ board.close()
135
+ except Exception:
136
+ pass
137
+
138
+ def _acquire_once(self) -> dict | None:
139
+ filename = datetime.now().strftime("%Y-%m-%d-%H.%M.%S.%f")[:-3]
140
+ stored = self._sample(filename)
141
+ if stored is None:
142
+ return None
143
+ with tempfile.TemporaryDirectory(prefix="oasis_pull_") as scratch:
144
+ data, t = stack_sources(self._pull_all(stored, scratch))
145
+ return {
146
+ "data": data,
147
+ "t": t,
148
+ "sample_rate": float(self.sample_rate),
149
+ }
150
+
151
+ def _sample(self, filename: str) -> str | None:
152
+ """Arm the primary board and wait until the capture is stored.
153
+
154
+ Returns the file name the boards stored the capture under, or None
155
+ when the acquisition was stopped. This is ``OasisBoard.acquire()``
156
+ without its final step: the capture stays on the boards so that every
157
+ board can be pulled at the same time afterwards.
158
+ """
159
+ board = OasisBoard(mode="serial", port=self.ports[0], baudrate=self.baudrate)
160
+ with self._board_lock:
161
+ self._board = board
162
+ try:
163
+ board.set_parameters(
164
+ t_sample=self.duration,
165
+ f_sample=self.sample_rate,
166
+ voltage_range=self.voltage_ranges[0],
167
+ trigger=self.trigger_level > 0,
168
+ v_trigg=self.trigger_level,
169
+ oversampling=self.oversampling_exp,
170
+ sync_mode=0,
171
+ )
172
+
173
+ @board.on_trigger()
174
+ def _on_trigger() -> None:
175
+ self.stateChanged.emit("triggered", "Impact detected - recording...")
176
+
177
+ board.connect()
178
+ board.set_oversampling_and_range_only()
179
+ try:
180
+ board.ser.reset_input_buffer()
181
+ except Exception:
182
+ pass
183
+
184
+ if self.trigger_level > 0:
185
+ self.stateChanged.emit(
186
+ "waiting",
187
+ f"Armed - waiting for impact (trigger {self.trigger_level:g} V on CH1)",
188
+ )
189
+ else:
190
+ self.stateChanged.emit("waiting", "Recording (free run)...")
191
+
192
+ trigger_level = board.trigger_level if board.trigger_mode else 0
193
+ command = (
194
+ f"OASIS.Sample({board.t_sample},{board.f_sample},"
195
+ f"{trigger_level},{board.sync_mode},{filename})"
196
+ )
197
+ board.ser.write((command + "\n").encode("utf-8"))
198
+ stored = board._wait_for_serial_sample_completion(
199
+ board.ser, filename, log=self.log.emit
200
+ )
201
+ return None if self._stop_requested else stored
202
+ finally:
203
+ with self._board_lock:
204
+ self._board = None
205
+ try:
206
+ board.close() # the capture travels over USB MSC, not over serial
207
+ except Exception:
208
+ pass
209
+
210
+ def _pull_all(self, filename: str, output_dir: str) -> list[dict]:
211
+ """Pull the capture from every board at once, in port order.
212
+
213
+ Seen on the bench: after a number of captures a board's USB MSC volume
214
+ no longer re-appears on the host and that board stops capturing until
215
+ it is rebooted. A failed transfer therefore reboots the boards that
216
+ failed, pushes the settings to them again and retries once; a second
217
+ failure is reported (that impact is lost).
218
+ """
219
+ count = len(self.ports)
220
+ detail = (
221
+ "Transferring data..."
222
+ if count == 1
223
+ else f"Transferring data from {count} boards in parallel..."
224
+ )
225
+ for attempt in range(1, PULL_ATTEMPTS + 1):
226
+ self._pull_percent = {}
227
+ self.stateChanged.emit(
228
+ "transferring", detail + (" (retry)" if attempt > 1 else "")
229
+ )
230
+ self.progress.emit(0)
231
+ try:
232
+ return collect_serial_sources_parallel(
233
+ filename=filename,
234
+ serial_ports=self.ports,
235
+ t_sample=self.duration,
236
+ f_sample=self.sample_rate,
237
+ output_dir=output_dir,
238
+ baudrate=self.baudrate,
239
+ progress=self._pull_progress,
240
+ )
241
+ except PullError as exc:
242
+ if attempt >= PULL_ATTEMPTS or self._stop_requested:
243
+ raise
244
+ self.log.emit(
245
+ f"[GUI] {exc}; rebooting the board(s) that failed and retrying..."
246
+ )
247
+ self._reboot_boards(exc.ports)
248
+ raise RuntimeError("unreachable")
249
+
250
+ def _pull_progress(self, port: str, percent: int) -> None:
251
+ """Mean transfer progress over all boards (called from pull threads)."""
252
+ self._pull_percent[port] = int(percent)
253
+ self.progress.emit(int(sum(self._pull_percent.values()) / len(self.ports)))
254
+
255
+ def _reboot_boards(self, ports: list[str]) -> None:
256
+ for port in ports:
257
+ if self._stop_requested:
258
+ return
259
+ self.stateChanged.emit("arming", f"Rebooting board on {port}...")
260
+ reboot_board(port, self.baudrate)
261
+ if any(port in ports for port in self.ports[1:]):
262
+ # a rebooted board lost its voltage ranges and oversampling
263
+ self._configure_secondary_boards()
264
+
265
+
266
+ # --- multi-board helpers (adapted from oasis-gui) ----------------------------
267
+ def collect_serial_sources_parallel(
268
+ filename: str,
269
+ serial_ports: list[str],
270
+ t_sample: float,
271
+ f_sample: float,
272
+ output_dir: str | None = None,
273
+ baudrate: int = DEFAULT_SERIAL_BAUDRATE,
274
+ timeout_s: float = 45.0,
275
+ progress=None, # callable(port, percent), called from the pull threads
276
+ ) -> list[dict]:
277
+ """Pull the stored capture from every board's USB MSC volume in parallel.
278
+
279
+ Each board copies from its own volume, so one thread per board is used and
280
+ the transfer takes as long as the slowest board rather than the sum of all
281
+ of them. Sources are returned in the order of ``serial_ports``; a board
282
+ that fails raises `PullError` naming every port that did not deliver.
283
+ """
284
+ ports = []
285
+ seen = set()
286
+ for port in serial_ports:
287
+ if port and port not in seen:
288
+ seen.add(port)
289
+ ports.append(port)
290
+ if not ports:
291
+ return []
292
+
293
+ if output_dir is None:
294
+ output_dir = tempfile.mkdtemp(prefix=f"oasis_pull_{filename}_")
295
+ index_map = {port: index for index, port in enumerate(ports)}
296
+ effective_timeout_s = max(float(timeout_s), float(t_sample) * 2.0 + 10.0)
297
+
298
+ def pull_one(port: str) -> dict:
299
+ board = OasisBoard(mode="serial", port=port, baudrate=baudrate)
300
+ result = board.collect_distributed_serial_files(
301
+ filename=filename,
302
+ serial_ports=[port],
303
+ t_sample=t_sample,
304
+ f_sample=f_sample,
305
+ output_dir=output_dir,
306
+ baudrate=baudrate,
307
+ timeout=effective_timeout_s,
308
+ print_log=lambda *_: None,
309
+ download_progress=progress,
310
+ )
311
+ if not result:
312
+ raise RuntimeError("No file collected.")
313
+ return result[0]
314
+
315
+ sources: list[dict] = []
316
+ failures: dict[str, str] = {}
317
+ with ThreadPoolExecutor(max_workers=len(ports)) as pool:
318
+ futures = {pool.submit(pull_one, port): port for port in ports}
319
+ for future in as_completed(futures):
320
+ port = futures[future]
321
+ try:
322
+ sources.append(future.result())
323
+ except Exception as exc:
324
+ failures[port] = str(exc)
325
+ if failures:
326
+ raise PullError({port: failures[port] for port in ports if port in failures})
327
+ sources.sort(key=lambda entry: index_map.get(entry.get("port", ""), 9999))
328
+ return sources
329
+
330
+
331
+ def stack_sources(sources: list[dict]) -> tuple[np.ndarray, np.ndarray]:
332
+ """Decode pulled captures and stack the boards' channels in port order.
333
+
334
+ Returns the data [channels, samples] in volts and the time axis of the
335
+ primary board (negative before the trigger). Captures that differ in
336
+ length by a sample are truncated to the shortest one.
337
+ """
338
+ if not sources:
339
+ raise RuntimeError("No capture was collected.")
340
+
341
+ blocks = []
342
+ times = []
343
+ for source in sources:
344
+ offline = OasisBoard(mode="offline")
345
+ offline.load_from_files(
346
+ meta_path=source["meta_path"],
347
+ raw_path=source.get("raw_path"),
348
+ pretrig_raw_path=source.get("pretrig_raw_path"),
349
+ print_log=lambda *_: None,
350
+ )
351
+ blocks.append(np.asarray(offline.OASISData, dtype=float))
352
+ times.append(np.asarray(offline.t, dtype=float))
353
+
354
+ target = int(min(block.shape[1] for block in blocks))
355
+ return np.vstack([block[:, :target] for block in blocks]), times[0][:target]
356
+
357
+
358
+ def reboot_board(port: str, baudrate: int = DEFAULT_SERIAL_BAUDRATE) -> None:
359
+ """``OASIS.Reboot()`` over serial; returns once the port re-enumerates.
360
+ The USB MSC volume follows a few seconds later (oasis-api waits for it
361
+ when the capture is pulled)."""
362
+ OasisBoard._reboot_serial_port_for_msc_refresh(port, baudrate=baudrate)
363
+
364
+
365
+ def list_serial_ports() -> list[str]:
366
+ from serial.tools import list_ports
367
+
368
+ return [info.device for info in list_ports.comports()]
369
+
370
+
371
+ def parse_device_info(raw: str) -> dict | None:
372
+ """Parse the ``OASIS.RawInfo()`` response (same layout as oasis-gui):
373
+ ``hw;fw;adc_bits;teds;wss;device_name;architecture``."""
374
+ cleaned = str(raw).replace("\r", "").replace("\n", "")
375
+ fields = [entry.strip() for entry in cleaned.split(";") if entry.strip()]
376
+ if not fields:
377
+ return None
378
+ if ")" in fields[0]:
379
+ fields[0] = fields[0].rsplit(")", 1)[-1].strip()
380
+ if len(fields) < 7:
381
+ return None
382
+ try:
383
+ return {
384
+ "hw_version": fields[0],
385
+ "fw_version": fields[1],
386
+ "adc_bits": int(fields[2]),
387
+ "teds": int(fields[3]),
388
+ "wss": int(fields[4]),
389
+ "device_name": fields[5],
390
+ "architecture": fields[6],
391
+ }
392
+ except ValueError:
393
+ return None
394
+
395
+
396
+ def read_device_info(port: str, baudrate: int = DEFAULT_SERIAL_BAUDRATE) -> dict:
397
+ """Connect to a board, read its stored device info, and disconnect."""
398
+ board = OasisBoard(mode="serial", port=port, baudrate=baudrate)
399
+ board.connect()
400
+ try:
401
+ raw = board.device_raw_info()
402
+ finally:
403
+ try:
404
+ board.close()
405
+ except Exception:
406
+ pass
407
+ fields = parse_device_info(raw)
408
+ if fields is None:
409
+ raise RuntimeError(f"Unexpected device info response: {raw!r}")
410
+ return fields