openm454 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.
openm454/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """openm454: driver and CLI for the DreamSourceLab DSCope M454 (USB 2A0E:0080, "v3" protocol).
2
+
3
+ Quick use:
4
+ from openm454 import Scope
5
+ with Scope() as s:
6
+ cap = s.capture(channels="A1,D0", timebase=500e-6)
7
+ """
8
+ __version__ = "0.1.0"
9
+ from .errors import DscopeError # noqa: F401
10
+
11
+
12
+ def __getattr__(name):
13
+ if name == "Scope":
14
+ from .scope import Scope
15
+ return Scope
16
+ if name == "load_capture":
17
+ from .capture import load_capture
18
+ return load_capture
19
+ raise AttributeError(name)
openm454/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ import sys
2
+ from .cli import main
3
+
4
+ sys.exit(main())
openm454/acquire.py ADDED
@@ -0,0 +1,437 @@
1
+ """Acquisition engine: run one register-programmed capture and collect the raw stream.
2
+
3
+ Mirrors DSWave 1.2.1 (UsbDevice_TransferThread 0x33f950, UsbBsp3x Setup/Start/StopAcquire and the UsbTransfer
4
+ callbacks; RVAs refer to DSWave.exe 1.2.1):
5
+
6
+ 1. >= 100 ms after the previous run and after the last register write: setup program
7
+ (W 0x01=0, settle, MSO writes ..., W 0x00 MODE, W 0xF9=0)
8
+ 2. submit EP 0x86 reads: one 4096-byte head transfer + N data transfers (no timeout); wait 50 ms
9
+ 3. start: W SYNC=sync, W ACTION=0, W CTRL=1; settle. From the moment W CTRL=1 is sent until the stop handshake is
10
+ done every register access sits inside an EP 0x84 handshake (Transport.running / handshake / handshake_writes)
11
+ 4. collect: data transfers are resubmitted from their callback for the WHOLE run, also after the record is
12
+ complete, because the device keeps streaming zero padding until it is stopped. While the head has not
13
+ arrived the progress register is polled like DSWave (handshake + R 0x02, every ~15 ms from 100 ms on)
14
+ 5. normal stop: handshake{W CTRL=0, W ACTION=8}; stop resubmitting; cancel every transfer and wait until all
15
+ are back; W CTRL=0, 1 ms, W ACTION=4
16
+ error stop (failed handshake or control transfer, bad head, transfer error, zero-length transfer,
17
+ resubmit failure): nothing more is sent to the device; cancel, wait, mark the session failed and raise
18
+ DEVICE_ERROR. As in DSWave's transfer thread, a completed record or an abort is handled before an error that
19
+ arrived with it: such an error gets the normal stop handshake and drain, but not the final writes.
20
+
21
+ Ctrl+C (SIGINT) during a run only requests the normal stop; KeyboardInterrupt is raised after the device has
22
+ been stopped. Exceptions inside the libusb callback become an error stop instead of being lost. Exceptions from the
23
+ caller's on_chunk / stop_flag are not device failures: the run is stopped normally and the exception re-raised.
24
+
25
+ DSWave's "1 ms" sleeps last ~15 ms on the Windows timer tick (recorded 14-31 ms), so the settle times below use
26
+ 15 ms to stay inside the recorded envelope.
27
+ """
28
+ import collections
29
+ import signal
30
+ import struct
31
+ import threading
32
+ import time
33
+
34
+ import usb1
35
+
36
+ from . import regs
37
+ from .errors import DscopeError
38
+ from .transport import HINT_REOPEN
39
+
40
+ HEAD_BYTES = 4096
41
+ HEAD_MAGIC = b'\x55\x55\x55\x55'
42
+ EP_DATA = 0x86
43
+ GAP_BETWEEN_RUNS = 0.100 # FUN_14033f270: 100 ms before every run
44
+ SETUP_SETTLE = 0.015 # SetupAcquire: "1 ms" after W CTRL=0 (recorded 14-31 ms)
45
+ SUBMIT_TO_START = 0.050 # TransferThread: 50 ms between submitting and the start writes
46
+ START_SETTLE = 0.015 # StartAcquire: "1 ms" after W CTRL=1 (first EP 0x84 read >= 14.7 ms later)
47
+ FINAL_GAP = 0.001 # ForceAcquire: W CTRL=0, 1 ms, W ACTION=4 (recorded 1.6-29 ms)
48
+ POLL_AFTER = 0.100 # progress polling starts 100 ms after the start writes
49
+ POLL_EVERY = 0.015
50
+ DRAIN_TIMEOUT = 2.0 # cancelled transfers must be back within this time
51
+ MAX_EVENTS_PER_PASS = 32 # events handled between two checks of abort / stop_flag / deadlines
52
+
53
+ _STATUS = {usb1.TRANSFER_COMPLETED: 'COMPLETED', usb1.TRANSFER_ERROR: 'ERROR', usb1.TRANSFER_TIMED_OUT: 'TIMED_OUT',
54
+ usb1.TRANSFER_CANCELLED: 'CANCELLED', usb1.TRANSFER_STALL: 'STALL', usb1.TRANSFER_NO_DEVICE: 'NO_DEVICE',
55
+ usb1.TRANSFER_OVERFLOW: 'OVERFLOW'}
56
+
57
+
58
+ class RawCapture:
59
+ def __init__(self):
60
+ self.head = None
61
+ self.data = bytearray()
62
+ self.n_data = 0 # data bytes received (also counted when keep_data=False leaves data empty)
63
+ self.frames = [] # repeat mode: one entry per data transfer (frame data + 4 KiB tail)
64
+ self.t_start = None
65
+ self.t_head = None
66
+ self.t_done = None
67
+ self.timed_out = False
68
+ self.aborted = False # ended early: abort() / shutdown() / SIGINT / stop_flag
69
+ self.stopped = False # ... by the caller's stop_flag (a user stop request)
70
+ self.ep84 = [] # every EP 0x84 handshake byte, in order (None = timeout)
71
+ self.handshakes = [] # (ms after start, 'poll'|'stop', [bytes])
72
+ self.progress = [] # raw R 0x02 values from the progress polls
73
+ self.stop = None # 'normal' | 'error' | None (never started)
74
+ self.error = None
75
+
76
+ @property
77
+ def head_words(self):
78
+ return struct.unpack('<8I', self.head[:32]) if self.head else None
79
+
80
+ @property
81
+ def triggered(self):
82
+ w = self.head_words
83
+ return None if w is None else not (w[2] & 0x80000000)
84
+
85
+
86
+ class Engine:
87
+ def __init__(self, device):
88
+ self.dev = device
89
+ self.t = device.t
90
+ self.sync = 0
91
+ self.active = False
92
+ self._t_end = time.time()
93
+ self._abort = threading.Event()
94
+ self._idle = threading.Event()
95
+ self._idle.set()
96
+ self._state = threading.Lock()
97
+ self._shutdown = False
98
+ self._stale = [] # transfers that never came back after a cancel (kept alive, never freed)
99
+ self._sigint = False
100
+
101
+ # ------------------------------------------------------------------ cross-thread control
102
+ def abort(self):
103
+ """Ask a running capture to stop (thread-safe). The run loop performs the normal stop handshake."""
104
+ self._abort.set()
105
+
106
+ def shutdown(self):
107
+ """Sticky abort (watchdog): stop the current run and refuse every later one."""
108
+ with self._state:
109
+ self._shutdown = True
110
+ self._abort.set()
111
+
112
+ def wait_idle(self, timeout):
113
+ return self._idle.wait(timeout)
114
+
115
+ # ------------------------------------------------------------------ transfers
116
+ def _submit(self, n):
117
+ tr = self.t.h.getTransfer()
118
+ tr.setBulk(EP_DATA, n, callback=self._cb, timeout=0)
119
+ self._trs.append(tr)
120
+ tr.submit()
121
+ return tr
122
+
123
+ def _cb(self, tr):
124
+ """Runs inside libusb event handling (handleEventsTimeout and every synchronous transfer, e.g. the EP 0x84
125
+ handshake reads). Data transfers are resubmitted here, like UsbTransfer_DataCallback, so the reads stay
126
+ queued during handshakes. An exception must not escape: ctypes would swallow it and the transfer would be
127
+ lost silently, so it becomes an error event."""
128
+ try:
129
+ if self._stopping or self.t.failed:
130
+ return # DataCallback: stopping or device in error -> free, no resubmit
131
+ st = tr.getStatus()
132
+ n = tr.getActualLength()
133
+ if tr is self._head_tr:
134
+ if st != usb1.TRANSFER_COMPLETED:
135
+ self._events.append(('error', 'head transfer ended with status %s' % _STATUS.get(st, st)))
136
+ return
137
+ buf = bytes(memoryview(tr.getBuffer())[:n])
138
+ if len(buf) != HEAD_BYTES or buf[:4] != HEAD_MAGIC:
139
+ self._events.append(('error', 'bad head packet (%d bytes, check id %s)' % (len(buf), buf[:4].hex())))
140
+ else:
141
+ self._events.append(('head', buf))
142
+ return
143
+ if st != usb1.TRANSFER_COMPLETED:
144
+ self._events.append(('error', 'data transfer ended with status %s' % _STATUS.get(st, st)))
145
+ return
146
+ if n == 0:
147
+ self._events.append(('error', 'zero-length data transfer'))
148
+ return
149
+ if not self._full:
150
+ self._events.append(('data', bytes(memoryview(tr.getBuffer())[:n])))
151
+ try:
152
+ tr.submit()
153
+ except usb1.USBError as e:
154
+ self._events.append(('error', 'data transfer resubmit failed: %s' % e))
155
+ except BaseException as e: # noqa: BLE001 - see docstring
156
+ self._events.append(('error', 'exception in the transfer callback: %r' % (e,)))
157
+
158
+ def _cancel_and_wait(self):
159
+ for tr in self._trs:
160
+ try:
161
+ if tr.isSubmitted():
162
+ tr.cancel()
163
+ except usb1.USBError:
164
+ pass
165
+ end = time.time() + DRAIN_TIMEOUT
166
+ while any(tr.isSubmitted() for tr in self._trs):
167
+ if time.time() > end:
168
+ stale = [tr for tr in self._trs if tr.isSubmitted()]
169
+ self._stale.extend(stale)
170
+ self.t.stale = True
171
+ self.t.stale_transfers.extend(stale)
172
+ return False
173
+ self.t.ctx.handleEventsTimeout(0.02)
174
+ self._events.clear()
175
+ for tr in self._trs:
176
+ try:
177
+ tr.close()
178
+ except Exception:
179
+ pass
180
+ return True
181
+
182
+ # ------------------------------------------------------------------ run
183
+ def run(self, setup_writes, pkt_bytes, need_bytes, n_transfers=16, trigger_timeout=5.0,
184
+ collect_timeout=10.0, repeat_frames=0, on_chunk=None, stop_flag=None, poll=True, settle=0.0,
185
+ keep_data=True):
186
+ """Execute one capture. need_bytes = data bytes (after the head) to collect.
187
+ repeat_frames > 0: repeat mode; after the head every data transfer is one frame (data + 4 KiB tail);
188
+ collect that many frames. on_chunk(bytes) is called for the head and for every data chunk as it arrives
189
+ (live streaming). stop_flag(): optional callable; the capture stops when it returns True.
190
+ settle: extra seconds between the setup writes and the start (front end recovering from overdrive).
191
+ collect_timeout=None: no deadline after the head (loop mode streams until stop_flag ends it).
192
+ keep_data=False: data chunks only go to on_chunk (cap.data stays empty, cap.n_data counts them), so an
193
+ endless stream does not grow in memory.
194
+ Returns a RawCapture; cap.head is None when nothing arrived before trigger_timeout. Raises DEVICE_ERROR
195
+ after an error stop (the session is then unusable until the device is reopened)."""
196
+ t = self.t
197
+ with self._state:
198
+ if self._shutdown:
199
+ raise DscopeError('TIMEOUT_WATCHDOG', 'the session is shutting down (watchdog)')
200
+ if self.active:
201
+ raise DscopeError('BUSY', 'a capture is already running on this engine')
202
+ self._abort.clear()
203
+ self._idle.clear()
204
+ self.active = True
205
+ self._sigint = False
206
+ restore = None
207
+ try:
208
+ if threading.current_thread() is threading.main_thread():
209
+ def on_sigint(signum, frame):
210
+ self._sigint = True
211
+ self._abort.set()
212
+ try:
213
+ restore = (signal.signal(signal.SIGINT, on_sigint),)
214
+ except (ValueError, OSError):
215
+ restore = None
216
+ if t.failed:
217
+ raise DscopeError('DEVICE_ERROR', 'device failed earlier in this session (%s)' % t.failed, HINT_REOPEN)
218
+ gap = max(self._t_end, t.t_last_write) + GAP_BETWEEN_RUNS - time.time()
219
+ if gap > 0:
220
+ time.sleep(gap)
221
+ cap = self._run(setup_writes, pkt_bytes, need_bytes, n_transfers, trigger_timeout, collect_timeout,
222
+ repeat_frames, on_chunk, stop_flag, poll, settle, keep_data)
223
+ finally:
224
+ self._t_end = time.time()
225
+ self.active = False
226
+ self._idle.set()
227
+ if restore is not None: # last: a SIGINT after this may raise right here
228
+ try:
229
+ signal.signal(signal.SIGINT, restore[0] if restore[0] is not None else signal.default_int_handler)
230
+ except (ValueError, OSError):
231
+ pass
232
+ if self._sigint:
233
+ raise KeyboardInterrupt
234
+ return cap
235
+
236
+ def _run(self, setup_writes, pkt_bytes, need_bytes, n_transfers, trigger_timeout, collect_timeout,
237
+ repeat_frames, on_chunk, stop_flag, poll, settle=0.0, keep_data=True):
238
+ t = self.t
239
+ cap = RawCapture()
240
+ self._events = collections.deque()
241
+ self._stopping = False
242
+ self._full = False
243
+ self._late_error = None
244
+ self._user_exc = None # exception from on_chunk / stop_flag (caller code)
245
+ self._trs = []
246
+ self._head_tr = None
247
+
248
+ # the device is idle here: setup writes are direct control transfers
249
+ for i, (addr, val) in enumerate(setup_writes):
250
+ t.write(addr, val)
251
+ if i == 0 and addr == regs.CTRL:
252
+ time.sleep(SETUP_SETTLE)
253
+ if settle > 0:
254
+ self._abort.wait(settle) # idle device: nothing is queued yet
255
+ if self._abort.is_set():
256
+ cap.aborted = True # stopped before the start: nothing to stop
257
+ return cap
258
+ try:
259
+ self._head_tr = self._submit(HEAD_BYTES)
260
+ for _ in range(n_transfers):
261
+ self._submit(pkt_bytes)
262
+ except usb1.USBError as e:
263
+ self._stopping = True # nothing was started: just take the reads back
264
+ self._cancel_and_wait()
265
+ raise DscopeError('USB_ERROR', 'cannot queue EP 0x86 reads: %s' % e, HINT_REOPEN)
266
+ time.sleep(SUBMIT_TO_START)
267
+
268
+ error = None
269
+ try:
270
+ try:
271
+ with t.guarded('run'):
272
+ t.write(regs.SYNC, self.sync)
273
+ t.write(regs.ACTION, 0x0000)
274
+ t.write(regs.CTRL, 0x0001) # Transport.running is set as this is sent
275
+ time.sleep(START_SETTLE)
276
+ cap.t_start = time.time()
277
+ error = self._collect(cap, need_bytes, trigger_timeout, collect_timeout, repeat_frames, on_chunk,
278
+ stop_flag, poll, keep_data)
279
+ except DscopeError as e: # failed start write or progress-poll handshake
280
+ error = 'start write failed: %s' % e.message if not cap.t_start else e.message
281
+ except BaseException as exc: # a bug in the engine loop: stop like a user stop, then re-raise
282
+ err = self._finish(cap, None)
283
+ if err:
284
+ raise DscopeError('DEVICE_ERROR', err, HINT_REOPEN) from exc
285
+ raise
286
+ error = self._finish(cap, error)
287
+ finally:
288
+ if not self._stopping: # something escaped before the reads were taken back
289
+ t.fail(t.failed or 'interrupted while stopping')
290
+ self._stopping = True
291
+ t.running = False
292
+ self._cancel_and_wait()
293
+ cap.t_done = time.time()
294
+ if error:
295
+ raise DscopeError('DEVICE_ERROR', error, HINT_REOPEN) from self._user_exc
296
+ if self._user_exc is not None: # caller code failed: the device was stopped normally first
297
+ raise self._user_exc
298
+ return cap
299
+
300
+ def _finish(self, cap, error):
301
+ """Normal stop when possible, error stop otherwise. Returns the error that remains (None = clean)."""
302
+ if error is None and self.t.running and not self.t.failed:
303
+ return self._stop_normal(cap)
304
+ self._stop_error(cap, error or self.t.failed or 'run ended in an unknown state')
305
+ return cap.error
306
+
307
+ def _collect(self, cap, need_bytes, trigger_timeout, collect_timeout, repeat_frames, on_chunk, stop_flag,
308
+ poll, keep_data=True):
309
+ """Pump events until the record is complete, the caller stops, or a deadline passes. Returns None, or an
310
+ error reason that requires the error stop."""
311
+ t = self.t
312
+ ctx = t.ctx
313
+ deadline_head = cap.t_start + trigger_timeout
314
+ deadline = None
315
+ last_poll = 0.0
316
+ polling = poll and not repeat_frames
317
+ while True:
318
+ ctx.handleEventsTimeout(0.005)
319
+ budget = MAX_EVENTS_PER_PASS # bounded, so stop requests and deadlines are always checked
320
+ while self._events and budget > 0:
321
+ budget -= 1
322
+ kind, buf = self._events.popleft()
323
+ if kind == 'error':
324
+ if self._full or self._abort.is_set():
325
+ # TransferThread checks 'frame over' / abort before error: stop normally first
326
+ self._late_error = self._late_error or buf
327
+ continue
328
+ return buf
329
+ if kind == 'head':
330
+ cap.head = buf
331
+ cap.t_head = time.time()
332
+ deadline = cap.t_head + collect_timeout if collect_timeout is not None else None
333
+ elif self._full:
334
+ continue # zero padding after the record: discarded
335
+ elif repeat_frames:
336
+ cap.frames.append(buf)
337
+ else:
338
+ cap.n_data += len(buf)
339
+ if keep_data:
340
+ cap.data += buf
341
+ if on_chunk is not None:
342
+ try:
343
+ on_chunk(buf)
344
+ except BaseException as e: # caller code: normal stop, re-raised by _run
345
+ self._user_exc = e
346
+ return None
347
+ if self._enough(cap, need_bytes, repeat_frames):
348
+ self._full = True
349
+ ctx.handleEventsTimeout(0) # keep resubmitting while a backlog is processed
350
+ if self._full:
351
+ return None
352
+ if self._abort.is_set():
353
+ cap.aborted = True
354
+ return None
355
+ if stop_flag is not None:
356
+ try:
357
+ stop = stop_flag()
358
+ except BaseException as e: # caller code: normal stop, re-raised by _run
359
+ self._user_exc = e
360
+ return None
361
+ if stop:
362
+ cap.aborted = cap.stopped = True
363
+ return None
364
+ now = time.time()
365
+ if cap.head is None:
366
+ if now > deadline_head:
367
+ cap.timed_out = True
368
+ return None
369
+ if polling and now - cap.t_start >= POLL_AFTER and now - last_poll >= POLL_EVERY:
370
+ last_poll = now
371
+ rec = []
372
+ try:
373
+ with t.handshake(rec):
374
+ v = t.read16(regs.ACTION)
375
+ finally:
376
+ cap.ep84 += rec
377
+ cap.handshakes.append((round((now - cap.t_start) * 1000, 1), 'poll', rec))
378
+ cap.progress.append(v)
379
+ elif deadline is not None and now > deadline:
380
+ cap.timed_out = True
381
+ return None
382
+
383
+ @staticmethod
384
+ def _enough(cap, need_bytes, repeat_frames):
385
+ if repeat_frames:
386
+ return len(cap.frames) >= repeat_frames
387
+ return cap.head is not None and need_bytes is not None and cap.n_data >= need_bytes
388
+
389
+ # ------------------------------------------------------------------ stop
390
+ def _stop_normal(self, cap):
391
+ """DSWave StopAcquire + Drain + ForceAcquire. Returns None, or the reason the run ended in the error state."""
392
+ t = self.t
393
+ rec = []
394
+ t_rel = round((time.time() - cap.t_start) * 1000, 1) if cap.t_start else None
395
+ try:
396
+ t.handshake_writes([(regs.CTRL, 0x0000), (regs.ACTION, 0x0008)], rec)
397
+ except DscopeError as e:
398
+ cap.ep84 += rec
399
+ cap.handshakes.append((t_rel, 'stop', rec))
400
+ self._stop_error(cap, 'stop handshake failed: %s' % e.message)
401
+ return cap.error
402
+ cap.ep84 += rec
403
+ cap.handshakes.append((t_rel, 'stop', rec))
404
+ if self._late_error is None: # data-path errors that arrived during the handshake
405
+ self._late_error = next((b for k, b in self._events if k == 'error'), None)
406
+ self._stopping = True
407
+ t.running = False
408
+ if not self._cancel_and_wait():
409
+ why = 'EP 0x86 reads did not come back within %.0f s of the cancel' % DRAIN_TIMEOUT
410
+ t.fail(why)
411
+ cap.stop, cap.error = 'error', why
412
+ return why
413
+ if self._late_error: # DSWave: error stop after the normal one -> ForceAcquire refused
414
+ t.fail(self._late_error)
415
+ cap.stop, cap.error = 'error', self._late_error
416
+ return cap.error
417
+ try:
418
+ t.write(regs.CTRL, 0x0000)
419
+ time.sleep(FINAL_GAP)
420
+ t.write(regs.ACTION, 0x0004)
421
+ t.hold(GAP_BETWEEN_RUNS) # writes right after this can be dropped (run without trigger)
422
+ except DscopeError as e:
423
+ why = 'final stop write failed: %s' % e.message
424
+ t.fail(why)
425
+ cap.stop, cap.error = 'error', why
426
+ return why
427
+ cap.stop = 'normal'
428
+ return None
429
+
430
+ def _stop_error(self, cap, why):
431
+ """DSWave error stop: send nothing; take the reads back; the session stays failed."""
432
+ t = self.t
433
+ t.fail(why)
434
+ self._stopping = True
435
+ t.running = False
436
+ self._cancel_and_wait()
437
+ cap.stop, cap.error = 'error', why
openm454/awg.py ADDED
@@ -0,0 +1,97 @@
1
+ """Built-in arbitrary waveform generator (MCX output, 0..3 V, sine/square up to 1 MHz, triangle up to 400 kHz).
2
+
3
+ Register access is indirect: W 0x08 = AWG register address, then W 0x07 = value (DSWave's log labels these
4
+ the other way round). Formulas fitted to DSWave 1.2.1's own register programs (tests/fixtures/scenarios/corpus3.json) and
5
+ verified by loopback on A0:
6
+ FCW (4/5) = floor(f_Hz * 40) (32-bit, lo/hi)
7
+ RPAR/FPAR = int(3846153.2 / f_Hz + 0.5) (14/15, 16/17; edge parameter, same for both)
8
+ PHASE (6/7) = phase_deg / 360 * 4e9
9
+ HIGH/LOW (8/9) = trunc(uV / 6e6 * 0.91525 * 1023) (~156.05 codes per volt)
10
+ DUTY (10/11) = duty_fraction * 4e9
11
+ RSLOPE/FSLOPE (12/13) = floor(68.75 / duty), floor(68.75 / (1 - duty))
12
+ WAVE (0): 0 sine, 1 triangle, 2 square, 3 noise; MODE (1): 0 basic
13
+ SWITCH (2) = 1 ... 0 brackets mode/wave/frequency updates; ENABLE (3): 2 = armed, 3 = output running
14
+ Stopped (ENABLE 2), the output holds the LOW level; awg_off also writes LOW = 0 so "off" means 0 V.
15
+ Noise (measured 2026-10-31): Gaussian-like around (low+high)/2 (+30..160 mV), sigma ~2.8 % of (high-low), peaks
16
+ about +-0.2 x (high-low), flat to ~2 MHz; the frequency setting has no effect.
17
+ """
18
+ import math
19
+
20
+ from .errors import DscopeError
21
+
22
+ A_WAVE, A_MODE, A_SWITCH, A_ENABLE = 0, 1, 2, 3
23
+ A_FCW0, A_FCW1, A_PH0, A_PH1, A_HIGH, A_LOW = 4, 5, 6, 7, 8, 9
24
+ A_DUTY0, A_DUTY1, A_RSLOPE, A_FSLOPE, A_RPAR0, A_RPAR1, A_FPAR0, A_FPAR1 = 10, 11, 12, 13, 14, 15, 16, 17
25
+ REG_DATA, REG_ADDR = 0x07, 0x08
26
+ WAVES = {'sine': 0, 'triangle': 1, 'square': 2, 'noise': 3}
27
+ F_MAX = {'sine': 1e6, 'square': 1e6, 'triangle': 400e3, 'noise': 1e6}
28
+ V_MAX = 3.0
29
+ MIN_PULSE_S = 380e-9
30
+
31
+
32
+ def program(wave='sine', freq=1000.0, high=3.0, low=0.0, duty=50.0, phase=0.0):
33
+ """Return the ordered list of (awg_addr, value) writes that configure (but do not start) the AWG."""
34
+ if wave not in WAVES:
35
+ raise DscopeError('INVALID_ARG', 'wave must be one of %s' % ', '.join(WAVES))
36
+ if not 0.1 <= freq <= F_MAX[wave]:
37
+ raise DscopeError('INVALID_ARG', '%s frequency must be 0.1 Hz .. %g Hz' % (wave, F_MAX[wave]))
38
+ if not 0.0 <= low < high <= V_MAX + 1e-9:
39
+ raise DscopeError('INVALID_ARG', 'levels must satisfy 0 <= low < high <= 3.0 V')
40
+ if not 0.1 <= duty <= 99.9:
41
+ raise DscopeError('INVALID_ARG', 'duty must be 0.1 .. 99.9 %')
42
+ if wave == 'square':
43
+ period = 1.0 / freq
44
+ if min(duty, 100 - duty) / 100 * period < MIN_PULSE_S * (1 - 1e-9): # 380 ns itself is allowed
45
+ raise DscopeError('INVALID_ARG', 'pulse narrower than 380 ns at this frequency/duty')
46
+ # DSWave's exact conversions (openm454/awg_modes.py replays its AWG handler write for write)
47
+ from .awg_modes import edge_param, level_code, micro, slopes
48
+ fcw = int((micro(freq) / 1e6) * 40.0)
49
+ rpar = edge_param(freq)
50
+ ph = int(((micro(phase % 360) // 1000000) / 360.0) * 4e9) & 0xFFFFFFFF # whole degrees, truncated
51
+ dw, rs, fs = slopes(duty)
52
+ dw &= 0xFFFFFFFF
53
+ hi, lo = level_code(high), level_code(low)
54
+ return [
55
+ (A_ENABLE, 0x0002),
56
+ (A_SWITCH, 1), (A_MODE, 0), (A_SWITCH, 0),
57
+ (A_SWITCH, 1), (A_WAVE, WAVES[wave]), (A_SWITCH, 0),
58
+ (A_SWITCH, 1), (A_FCW0, fcw & 0xFFFF), (A_FCW1, fcw >> 16), (A_RPAR0, rpar & 0xFFFF), (A_RPAR1, rpar >> 16),
59
+ (A_FPAR0, rpar & 0xFFFF), (A_FPAR1, rpar >> 16), (A_SWITCH, 0),
60
+ (A_PH0, ph & 0xFFFF), (A_PH1, ph >> 16),
61
+ (A_HIGH, hi), (A_LOW, lo),
62
+ (A_DUTY0, dw & 0xFFFF), (A_DUTY1, dw >> 16), (A_RSLOPE, rs & 0xFFFF), (A_FSLOPE, fs & 0xFFFF),
63
+ ]
64
+
65
+
66
+ START = [(A_SWITCH, 1), (A_ENABLE, 0x0003), (A_SWITCH, 0)]
67
+ STOP = [(A_ENABLE, 0x0002)]
68
+ #: after STOP the output holds the programmed LOW level (measured 2026-10-31); LOW = 0 parks it at 0 V. The next
69
+ #: program() rewrites LOW.
70
+ PARK = [(A_LOW, 0)]
71
+
72
+
73
+ def dc(level):
74
+ """Writes for a steady DC output: stopped, the generator holds LOW (not a DSWave feature; measured)."""
75
+ if not 0.0 <= level <= V_MAX + 1e-9:
76
+ raise DscopeError('INVALID_ARG', 'DC level must be 0 .. 3.0 V')
77
+ from .awg_modes import level_code
78
+ return STOP + [(A_LOW, level_code(level))]
79
+
80
+
81
+ def apply(transport, writes):
82
+ for addr, val in writes:
83
+ transport.write(REG_ADDR, addr)
84
+ transport.write(REG_DATA, val)
85
+
86
+
87
+ CODES_PER_V = 1023 * 0.91525 / 6.0 # HIGH/LOW code per volt (DSWave: trunc(uV / 6e6 * 0.91525 * 1023))
88
+
89
+
90
+ def describe(wave, freq, high, low, duty, phase):
91
+ from .awg_modes import level_code
92
+ fcw = int(math.floor(freq * 40))
93
+ if wave == 'noise':
94
+ return {'wave': wave, 'high_v': high, 'low_v': low, 'note': 'Gaussian-like noise around (low+high)/2 with sigma '
95
+ 'about 2.8 % of (high-low), peaks about +-0.2 x (high-low), flat to ~2 MHz; frequency is ignored'}
96
+ return {'wave': wave, 'freq_hz': freq, 'freq_actual_hz': fcw / 40.0, 'high_v': round(level_code(high) / CODES_PER_V, 4),
97
+ 'low_v': round(level_code(low) / CODES_PER_V, 4), 'duty_pct': duty, 'phase_deg': phase}