├── .gitignore ├── VM registers.xlsx ├── utils.py ├── test.py ├── LICENSE ├── README.md ├── analysis.py ├── streams.py ├── vm.py └── scope.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .*.swp 3 | __pycache__/ 4 | -------------------------------------------------------------------------------- /VM registers.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonathanhogg/scopething/HEAD/VM registers.xlsx -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | utils 3 | ===== 4 | 5 | Random utility classes/functions. 6 | """ 7 | 8 | 9 | class DotDict(dict): 10 | __getattr__ = dict.__getitem__ 11 | __setattr__ = dict.__setitem__ 12 | __delattr__ = dict.__delitem__ 13 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | 2 | from pylab import figure, plot, show 3 | 4 | from analysis import annotate_series 5 | from scope import await_, capture, main 6 | 7 | 8 | await_(main()) 9 | series = capture(['A'], period=20e-3, nsamples=2000).A 10 | 11 | figure(1) 12 | plot(series.timestamps, series.samples) 13 | 14 | if annotate_series(series): 15 | waveform = series.waveform 16 | if 'duty_cycle' in waveform: 17 | print(f"Found {waveform.frequency:.0f}Hz {waveform.shape} wave, " 18 | f"with duty cycle {waveform.duty_cycle * 100:.0f}%, " 19 | f"amplitude ±{waveform.amplitude:.1f}V and offset {waveform.offset:.1f}V") 20 | else: 21 | print(f"Found {waveform.frequency:.0f}Hz {waveform.shape} wave, " 22 | f"with amplitude ±{waveform.amplitude:.2f}V and offset {waveform.offset:.2f}V") 23 | 24 | plot(waveform.timestamps + waveform.capture_start - series.capture_start, 25 | waveform.samples * waveform.amplitude + waveform.offset) 26 | 27 | show() 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Jonathan Hogg 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | 8 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 9 | 10 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # ScopeThing 3 | 4 | ## Quick Notes 5 | 6 | - Only tested against a BitScope Micro (BS05) 7 | - Requires Python >3.6 and the **pyserial** package 8 | - Also requires **NumPy** and **SciPy** if you want to do analog range calibration 9 | - Having **Pandas** is useful for wrapping capture results for further processing 10 | 11 | ## Longer Notes 12 | 13 | ### Why have I written this? 14 | 15 | BitScope helpfully provide applications and libraries for talking to their USB 16 | capture devices, so one can just use those. I wrote this code because I want 17 | to be able to grab the raw data and further process it in various ways. I'm 18 | accustomed to working at a Python prompt with various toolkits like SciPy and 19 | Matplotlib, so I wanted a simple way to just grab a trace as an array. 20 | 21 | The BitScope library is pretty simple to use, but also requires you to 22 | understand a fair amount about how the scope works and make a bunch of decisions 23 | about what capture mode and rate to use. I want to just specify a time period, 24 | voltage range and a rough number of samples and have all of that worked out for 25 | me, same way as I'd use an actual oscilloscope: twiddle the knobs and look at 26 | the trace. 27 | 28 | Of course, I could have wrapped the BitScope library in something that would do 29 | this, but after reading a bit about how the scope works I was fascinated with 30 | understanding it further and so I decided to go back to first principles and 31 | start with just talking to it with a serial library. This code thus serves as 32 | (sort of) documentation for the VM registers, capture modes and how to use them. 33 | It also has the advantage of being pure Python. 34 | 35 | The code prefers the highest capture resolution possible and will do the mapping 36 | from high/low/trigger voltages to the mysterious magic numbers that the device 37 | needs. It can also do logic and mixed-signal capture. 38 | 39 | In addition to capturing, the code can also generate waveforms at arbitrary 40 | frequencies – something that is tricky to do as the device operates at specific 41 | frequencies and so one has to massage the width of the waveform buffer to get a 42 | frequency outside of these. It can also control the clock generator. 43 | 44 | I've gone for an underlying async design as it makes it easy to integrate the 45 | code into UI programs or network servers – both of which interest me as the end 46 | purpose for this code. However, for shell use there are synchronous wrapper 47 | functions. Of particular note is that the synchronous wrapper understands 48 | keyboard interrupt and will cancel a capture returning the trace around the 49 | cancel point. This is useful if your trigger doesn't fire and you want to 50 | understand why. 51 | 52 | ### Where's the documentation, mate? 53 | 54 | Yeah, yeah. I know. 55 | 56 | ### Also, I see no unit tests... 57 | 58 | It's pretty hard to do unit tests for a physical device. That's my excuse and 59 | I'm sticking to it. 60 | 61 | ### Long lines and ignoring E221, eh? 62 | 63 | "A foolish consistency is the hobgoblin of little minds" 64 | 65 | Also, I haven't used an 80 character wide terminal in this century. 66 | -------------------------------------------------------------------------------- /analysis.py: -------------------------------------------------------------------------------- 1 | """ 2 | analysis 3 | ======== 4 | 5 | Library code for analysing captures returned by `Scope.capture()`. 6 | """ 7 | 8 | # pylama:ignore=C0103,R1716 9 | 10 | import numpy as np 11 | 12 | from utils import DotDict 13 | 14 | 15 | def interpolate_min_x(f, x): 16 | return 0.5 * (f[x-1] - f[x+1]) / (f[x-1] - 2 * f[x] + f[x+1]) + x 17 | 18 | 19 | def rms(f): 20 | return np.sqrt((f ** 2).mean()) 21 | 22 | 23 | def sine_wave(n): 24 | return np.sin(np.linspace(0, 2*np.pi, n, endpoint=False)) 25 | 26 | 27 | def triangle_wave(n): 28 | x = np.linspace(0, 4, n, endpoint=False) 29 | x2 = x % 2 30 | y = np.where(x2 < 1, x2, 2 - x2) 31 | y = np.where(x // 2 < 1, y, -y) 32 | return y 33 | 34 | 35 | def square_wave(n, duty=0.5): 36 | w = int(n * duty) 37 | return np.hstack([np.ones(w), -np.ones(n - w)]) 38 | 39 | 40 | def sawtooth_wave(n): 41 | return 2 * (np.linspace(0.5, 1.5, n, endpoint=False) % 1) - 1 42 | 43 | 44 | def moving_average(samples, width, mode='wrap'): 45 | hwidth = width // 2 46 | samples = np.take(samples, np.arange(-hwidth, len(samples)+width-hwidth), mode=mode) 47 | cumulative = samples.cumsum() 48 | return (cumulative[width:] - cumulative[:-width]) / width 49 | 50 | 51 | def calculate_periodicity(series, window=0.1): 52 | samples = np.array(series.samples, dtype='double') 53 | window = int(len(samples) * window) 54 | errors = np.zeros(len(samples) - window) 55 | for i in range(1, len(errors) + 1): 56 | errors[i-1] = rms(samples[i:] - samples[:-i]) 57 | threshold = errors.max() / 2 58 | minima = [] 59 | for i in range(window, len(errors) - window): 60 | p = errors[i-window:i+window].argmin() 61 | if p == window and errors[p + i - window] < threshold: 62 | minima.append(interpolate_min_x(errors, i)) 63 | if len(minima) <= 1: 64 | return None 65 | ks = np.polyfit(np.arange(0, len(minima)), minima, 1) 66 | return ks[0] / series.sample_rate 67 | 68 | 69 | def extract_waveform(series, period): 70 | p = int(round(series.sample_rate * period)) 71 | n = len(series.samples) // p 72 | if n <= 2: 73 | return None, None, None, None 74 | samples = np.array(series.samples)[:p*n] 75 | cumsum = samples.cumsum() 76 | underlying = (cumsum[p:] - cumsum[:-p]) / p 77 | n -= 1 78 | samples = samples[p//2:p*n + p//2] - underlying 79 | wave = np.zeros(p) 80 | for i in range(n): 81 | o = i * p 82 | wave += samples[o:o+p] 83 | wave /= n 84 | return wave, p//2, n, underlying 85 | 86 | 87 | def normalize_waveform(samples, smooth=7): 88 | n = len(samples) 89 | smoothed = moving_average(samples, smooth) 90 | scale = (smoothed.max() - smoothed.min()) / 2 91 | offset = (smoothed.max() + smoothed.min()) / 2 92 | smoothed -= offset 93 | last_rising = first_falling = None 94 | crossings = [] 95 | for i in range(n): 96 | if smoothed[i-1] < 0 and smoothed[i] > 0: 97 | last_rising = i 98 | elif smoothed[i-1] > 0 and smoothed[i] < 0: 99 | if last_rising is None: 100 | first_falling = i 101 | else: 102 | crossings.append((i - last_rising, last_rising)) 103 | if first_falling is not None: 104 | crossings.append((n + first_falling - last_rising, last_rising)) 105 | first = min(crossings)[1] 106 | wave = (np.hstack([samples[first:], samples[:first]]) - offset) / scale 107 | return wave, offset, scale, first, sorted((i - first % n, w) for (w, i) in crossings) 108 | 109 | 110 | def characterize_waveform(samples, crossings): 111 | n = len(samples) 112 | possibles = [] 113 | if len(crossings) == 1: 114 | duty_cycle = crossings[0][1] / n 115 | if 0.45 < duty_cycle < 0.55: 116 | possibles.append((rms(samples - sine_wave(n)), 'sine', None)) 117 | possibles.append((rms(samples - triangle_wave(n)), 'triangle', None)) 118 | possibles.append((rms(samples - sawtooth_wave(n)), 'sawtooth', None)) 119 | possibles.append((rms(samples - square_wave(n, duty_cycle)), 'square', duty_cycle)) 120 | possibles.sort() 121 | return possibles 122 | 123 | 124 | def annotate_series(series): 125 | period = calculate_periodicity(series) 126 | if period is not None: 127 | waveform = DotDict(period=period, frequency=1 / period) 128 | wave, start, count, underlying = extract_waveform(series, period) 129 | wave, offset, scale, first, crossings = normalize_waveform(wave) 130 | waveform.samples = wave 131 | waveform.beginning = start + first 132 | waveform.count = count 133 | waveform.amplitude = scale 134 | waveform.offset = underlying.mean() + offset 135 | waveform.timestamps = np.arange(len(wave)) * series.sample_period 136 | waveform.sample_period = series.sample_period 137 | waveform.sample_rate = series.sample_rate 138 | waveform.capture_start = series.capture_start + waveform.beginning * series.sample_period 139 | possibles = characterize_waveform(wave, crossings) 140 | if possibles: 141 | error, shape, duty_cycle = possibles[0] 142 | waveform.error = error 143 | waveform.shape = shape 144 | if duty_cycle is not None: 145 | waveform.duty_cycle = duty_cycle 146 | else: 147 | waveform.shape = 'unknown' 148 | series.waveform = waveform 149 | return True 150 | return False 151 | -------------------------------------------------------------------------------- /streams.py: -------------------------------------------------------------------------------- 1 | """ 2 | streams 3 | ======= 4 | 5 | Package for asynchronous serial IO. 6 | """ 7 | 8 | # pylama:ignore=W1203,R0916,W0703 9 | 10 | import asyncio 11 | import logging 12 | import sys 13 | import threading 14 | 15 | import serial 16 | from serial.tools.list_ports import comports 17 | 18 | 19 | Log = logging.getLogger(__name__) 20 | 21 | 22 | class SerialStream: 23 | 24 | @classmethod 25 | def devices_matching(cls, vid=None, pid=None, serial_number=None): 26 | for port in comports(): 27 | if (vid is None or vid == port.vid) and (pid is None or pid == port.pid) and (serial_number is None or serial_number == port.serial_number): 28 | yield port.device 29 | 30 | @classmethod 31 | def stream_matching(cls, vid=None, pid=None, serial_number=None, **kwargs): 32 | for device in cls.devices_matching(vid, pid, serial_number): 33 | return SerialStream(device, **kwargs) 34 | raise RuntimeError("No matching serial device") 35 | 36 | def __init__(self, device, use_threads=None, loop=None, **kwargs): 37 | self._device = device 38 | self._use_threads = sys.platform == 'win32' if use_threads is None else use_threads 39 | self._connection = serial.Serial(self._device, **kwargs) if self._use_threads else \ 40 | serial.Serial(self._device, timeout=0, write_timeout=0, **kwargs) 41 | Log.debug(f"Opened SerialStream on {device}") 42 | self._loop = loop if loop is not None else asyncio.get_event_loop() 43 | self._output_buffer = bytes() 44 | self._output_buffer_empty = None 45 | self._output_buffer_lock = threading.Lock() if self._use_threads else None 46 | 47 | def __repr__(self): 48 | return f'<{self.__class__.__name__}:{self._device}>' 49 | 50 | def close(self): 51 | if self._connection is not None: 52 | self._connection.close() 53 | self._connection = None 54 | 55 | def write(self, data): 56 | if self._use_threads: 57 | with self._output_buffer_lock: 58 | self._output_buffer += data 59 | if self._output_buffer_empty is None: 60 | self._output_buffer_empty = self._loop.run_in_executor(None, self._write_blocking) 61 | return 62 | if not self._output_buffer: 63 | try: 64 | nbytes = self._connection.write(data) 65 | except serial.SerialTimeoutException: 66 | nbytes = 0 67 | except Exception: 68 | Log.exception("Error writing to stream") 69 | raise 70 | if nbytes: 71 | Log.debug(f"Write {data[:nbytes]!r}") 72 | self._output_buffer = data[nbytes:] 73 | else: 74 | self._output_buffer += data 75 | if self._output_buffer and self._output_buffer_empty is None: 76 | self._output_buffer_empty = self._loop.create_future() 77 | self._loop.add_writer(self._connection, self._feed_data) 78 | 79 | async def drain(self): 80 | if self._output_buffer_empty is not None: 81 | await self._output_buffer_empty 82 | 83 | def _feed_data(self): 84 | try: 85 | nbytes = self._connection.write(self._output_buffer) 86 | except serial.SerialTimeoutException: 87 | nbytes = 0 88 | except Exception as exc: 89 | Log.exception("Error writing to stream") 90 | self._output_buffer_empty.set_exception(exc) 91 | self._loop.remove_writer(self._connection) 92 | if nbytes: 93 | Log.debug(f"Write {self._output_buffer[:nbytes]!r}") 94 | self._output_buffer = self._output_buffer[nbytes:] 95 | if not self._output_buffer: 96 | self._loop.remove_writer(self._connection) 97 | self._output_buffer_empty.set_result(None) 98 | self._output_buffer_empty = None 99 | 100 | def _write_blocking(self): 101 | with self._output_buffer_lock: 102 | while self._output_buffer: 103 | data = bytes(self._output_buffer) 104 | self._output_buffer_lock.release() 105 | try: 106 | nbytes = self._connection.write(data) 107 | finally: 108 | self._output_buffer_lock.acquire() 109 | Log.debug(f"Write {self._output_buffer[:nbytes]!r}") 110 | self._output_buffer = self._output_buffer[nbytes:] 111 | self._output_buffer_empty = None 112 | 113 | async def read(self, nbytes=None): 114 | if self._use_threads: 115 | return await self._loop.run_in_executor(None, self._read_blocking, nbytes) 116 | while True: 117 | nwaiting = self._connection.in_waiting 118 | if nwaiting: 119 | data = self._connection.read(nwaiting if nbytes is None else min(nbytes, nwaiting)) 120 | Log.debug(f"Read {data!r}") 121 | return data 122 | future = self._loop.create_future() 123 | self._loop.add_reader(self._connection, future.set_result, None) 124 | try: 125 | await future 126 | finally: 127 | self._loop.remove_reader(self._connection) 128 | 129 | def _read_blocking(self, nbytes=None): 130 | data = self._connection.read(1) 131 | nwaiting = self._connection.in_waiting 132 | if nwaiting and (nbytes is None or nbytes > 1): 133 | data += self._connection.read(nwaiting if nbytes is None else min(nbytes-1, nwaiting)) 134 | Log.debug(f"Read {data!r}") 135 | return data 136 | 137 | async def readexactly(self, nbytes): 138 | data = b'' 139 | while len(data) < nbytes: 140 | data += await self.read(nbytes-len(data)) 141 | return data 142 | -------------------------------------------------------------------------------- /vm.py: -------------------------------------------------------------------------------- 1 | """ 2 | vm 3 | == 4 | 5 | Package capturing BitScope VM specification, including registers, enumerations, flags, 6 | commands and logic for encoding and decoding virtual machine instructions and data. 7 | 8 | All names and descriptions copyright BitScope and taken from their [VM specification 9 | document][VM01B] (with slight changes). 10 | 11 | [VM01B]: https://docs.google.com/document/d/1cZNRpSPAMyIyAvIk_mqgEByaaHzbFTX8hWglAMTlnHY 12 | 13 | """ 14 | 15 | # pylama:ignore=E221,C0326,R0904,W1203 16 | 17 | import array 18 | from collections import namedtuple 19 | from enum import IntEnum 20 | import logging 21 | import struct 22 | 23 | from utils import DotDict 24 | 25 | 26 | Log = logging.getLogger(__name__) 27 | 28 | 29 | class Register(namedtuple('Register', ['base', 'dtype', 'description'])): 30 | def encode(self, value): 31 | sign = self.dtype[0] 32 | if '.' in self.dtype: 33 | whole, fraction = map(int, self.dtype[1:].split('.', 1)) 34 | width = whole + fraction 35 | value = int(round(value * (1 << fraction))) 36 | else: 37 | width = int(self.dtype[1:]) 38 | if sign == 'U': 39 | max_value = (1 << width) - 1 40 | value = min(max(0, value), max_value) 41 | data = struct.pack(' Low, 1 => High)"), 85 | "TriggerMask": Register(0x06, 'U8', "Trigger Mask, one bit per channel (0 => Don’t Care, 1 => Active)"), 86 | "SpockOption": Register(0x07, 'U8', "Spock Option Register (see bit definition table for details)"), 87 | "SampleAddress": Register(0x08, 'U24', "Sample address (write) 24 bit"), 88 | "SampleCounter": Register(0x0b, 'U24', "Sample address (read) 24 bit"), 89 | "TriggerIntro": Register(0x32, 'U16', "Edge trigger intro filter counter (samples/2)"), 90 | "TriggerOutro": Register(0x34, 'U16', "Edge trigger outro filter counter (samples/2)"), 91 | "TriggerValue": Register(0x44, 'S0.16', "Digital (comparator) trigger (signed)"), 92 | "TriggerTime": Register(0x40, 'U32', "Stopwatch trigger time (ticks)"), 93 | "ClockTicks": Register(0x2e, 'U16', "Sample period (ticks)"), 94 | "ClockScale": Register(0x14, 'U16', "Sample clock divider"), 95 | "TraceOption": Register(0x20, 'U8', "Trace Mode Option bits"), 96 | "TraceMode": Register(0x21, 'U8', "Trace Mode (see Trace Mode Table)"), 97 | "TraceIntro": Register(0x26, 'U16', "Pre-trigger capture count (samples)"), 98 | "TraceDelay": Register(0x22, 'U32', "Delay period (uS)"), 99 | "TraceOutro": Register(0x2a, 'U16', "Post-trigger capture count (samples)"), 100 | "Timeout": Register(0x2c, 'U16', "Auto trace timeout (auto-ticks)"), 101 | "Prelude": Register(0x3a, 'U16', "Buffer prefill value"), 102 | "BufferMode": Register(0x31, 'U8', "Buffer mode"), 103 | "DumpMode": Register(0x1e, 'U8', "Dump mode"), 104 | "DumpChan": Register(0x30, 'U8', "Dump (buffer) Channel (0..127,128..254,255)"), 105 | "DumpSend": Register(0x18, 'U16', "Dump send (samples)"), 106 | "DumpSkip": Register(0x1a, 'U16', "Dump skip (samples)"), 107 | "DumpCount": Register(0x1c, 'U16', "Dump size (samples)"), 108 | "DumpRepeat": Register(0x16, 'U16', "Dump repeat (iterations)"), 109 | "StreamIdent": Register(0x36, 'U8', "Stream data token"), 110 | "StampIdent": Register(0x3c, 'U8', "Timestamp token"), 111 | "AnalogEnable": Register(0x37, 'U8', "Analog channel enable (bitmap)"), 112 | "DigitalEnable": Register(0x38, 'U8', "Digital channel enable (bitmap)"), 113 | "SnoopEnable": Register(0x39, 'U8', "Frequency (snoop) channel enable (bitmap)"), 114 | "Cmd": Register(0x46, 'U8', "Command Vector"), 115 | "Mode": Register(0x47, 'U8', "Operation Mode (per command)"), 116 | "Option": Register(0x48, 'U16', "Command Option (bits fields per command)"), 117 | "Size": Register(0x4a, 'U16', "Operation (unit/block) size"), 118 | "Index": Register(0x4c, 'U16', "Operation index (eg, P Memory Page)"), 119 | "Address": Register(0x4e, 'U16', "General purpose address"), 120 | "Clock": Register(0x50, 'U16', "Sample (clock) period (ticks)"), 121 | "Modulo": Register(0x52, 'U16', "Modulo Size (generic)"), 122 | "Level": Register(0x54, 'U0.16', "Output (analog) attenuation (unsigned)"), 123 | "Offset": Register(0x56, 'S0.16', "Output (analog) offset (signed)"), 124 | "Mask": Register(0x58, 'U16', "Translate source modulo mask"), 125 | "Ratio": Register(0x5a, 'U16.16', "Translate command ratio (phase step)"), 126 | "Mark": Register(0x5e, 'U16', "Mark count/phase (ticks/step)"), 127 | "Space": Register(0x60, 'U16', "Space count/phase (ticks/step)"), 128 | "Rise": Register(0x82, 'U16', "Rising edge clock (channel 1) phase (ticks)"), 129 | "Fall": Register(0x84, 'U16', "Falling edge clock (channel 1) phase (ticks)"), 130 | "Control": Register(0x86, 'U8', "Clock Control Register (channel 1)"), 131 | "Rise2": Register(0x88, 'U16', "Rising edge clock (channel 2) phase (ticks)"), 132 | "Fall2": Register(0x8a, 'U16', "Falling edge clock (channel 2) phase (ticks)"), 133 | "Control2": Register(0x8c, 'U8', "Clock Control Register (channel 2)"), 134 | "Rise3": Register(0x8e, 'U16', "Rising edge clock (channel 3) phase (ticks)"), 135 | "Fall3": Register(0x90, 'U16', "Falling edge clock (channel 3) phase (ticks)"), 136 | "Control3": Register(0x92, 'U8', "Clock Control Register (channel 3)"), 137 | "EepromData": Register(0x10, 'U8', "EE Data Register"), 138 | "EepromAddress": Register(0x11, 'U8', "EE Address Register"), 139 | "ConverterLo": Register(0x64, 'U0.16', "VRB ADC Range Bottom (D Trace Mode)"), 140 | "ConverterHi": Register(0x66, 'U0.16', "VRB ADC Range Top (D Trace Mode)"), 141 | "TriggerLevel": Register(0x68, 'U0.16', "Trigger Level (comparator, unsigned)"), 142 | "LogicControl": Register(0x74, 'U8', "Logic Control"), 143 | "Rest": Register(0x78, 'U16', "DAC (rest) level"), 144 | "KitchenSinkA": Register(0x7b, 'U8', "Kitchen Sink Register A"), 145 | "KitchenSinkB": Register(0x7c, 'U8', "Kitchen Sink Register B"), 146 | "Map0": Register(0x94, 'U8', "Peripheral Pin Select Channel 0"), 147 | "Map1": Register(0x95, 'U8', "Peripheral Pin Select Channel 1"), 148 | "Map2": Register(0x96, 'U8', "Peripheral Pin Select Channel 2"), 149 | "Map3": Register(0x97, 'U8', "Peripheral Pin Select Channel 3"), 150 | "Map4": Register(0x98, 'U8', "Peripheral Pin Select Channel 4"), 151 | "Map5": Register(0x99, 'U8', "Peripheral Pin Select Channel 5"), 152 | "Map6": Register(0x9a, 'U8', "Peripheral Pin Select Channel 6"), 153 | "Map7": Register(0x9b, 'U8', "Peripheral Pin Select Channel 7"), 154 | "PrimaryClockN": Register(0xf7, 'U8', "PLL prescale (DIV N)"), 155 | "PrimaryClockM": Register(0xf8, 'U16', "PLL multiplier (MUL M)"), 156 | }) 157 | 158 | 159 | class TraceMode(IntEnum): 160 | Analog = 0 161 | Mixed = 1 162 | AnalogChop = 2 163 | MixedChop = 3 164 | AnalogFast = 4 165 | MixedFast = 5 166 | AnalogFastChop = 6 167 | MixedFastChop = 7 168 | AnalogShot = 11 169 | MixedShot = 12 170 | LogicShot = 13 171 | Logic = 14 172 | LogicFast = 15 173 | AnalogShotChop = 16 174 | MixedShotChop = 17 175 | Macro = 18 176 | MacroChop = 19 177 | 178 | 179 | class BufferMode(IntEnum): 180 | Single = 0 181 | Chop = 1 182 | Dual = 2 183 | ChopDual = 3 184 | Macro = 4 185 | MacroChop = 5 186 | 187 | 188 | class DumpMode(IntEnum): 189 | Raw = 0 190 | Burst = 1 191 | Summed = 2 192 | MinMax = 3 193 | AndOr = 4 194 | Native = 5 195 | Filter = 6 196 | Span = 7 197 | 198 | 199 | class SpockOption(IntEnum): 200 | TriggerInvert = 0x40 201 | TriggerSourceA = 0x04 * 0 202 | TriggerSourceB = 0x04 * 1 203 | TriggerSwap = 0x02 204 | TriggerTypeSampledAnalog = 0x01 * 0 205 | TriggerTypeHardwareComparator = 0x01 * 1 206 | 207 | 208 | class KitchenSinkA(IntEnum): 209 | ChannelAComparatorEnable = 0x80 210 | ChannelBComparatorEnable = 0x40 211 | 212 | 213 | class KitchenSinkB(IntEnum): 214 | AnalogFilterEnable = 0x80 215 | WaveformGeneratorEnable = 0x40 216 | 217 | 218 | class TraceStatus(IntEnum): 219 | Done = 0x00 220 | Auto = 0x01 221 | Wait = 0x02 222 | Stop = 0x03 223 | 224 | 225 | CaptureMode = namedtuple('CaptureMode', ('trace_mode', 'clock_low', 'clock_high', 'clock_divide', 226 | 'analog_channels', 'sample_width', 'logic_channels', 'buffer_mode')) 227 | 228 | 229 | CaptureModes = [ 230 | CaptureMode(TraceMode.Macro, 40, 16384, 1, 1, 2, False, BufferMode.Macro), 231 | CaptureMode(TraceMode.MacroChop, 40, 16384, 1, 2, 2, False, BufferMode.MacroChop), 232 | CaptureMode(TraceMode.Analog, 15, 40, 16383, 1, 1, False, BufferMode.Single), 233 | CaptureMode(TraceMode.AnalogChop, 13, 40, 16383, 2, 1, False, BufferMode.Chop), 234 | CaptureMode(TraceMode.AnalogFast, 8, 14, 1, 1, 1, False, BufferMode.Single), 235 | CaptureMode(TraceMode.AnalogFastChop, 8, 40, 1, 2, 1, False, BufferMode.Chop), 236 | CaptureMode(TraceMode.AnalogShot, 2, 5, 1, 1, 1, False, BufferMode.Single), 237 | CaptureMode(TraceMode.AnalogShotChop, 4, 5, 1, 2, 1, False, BufferMode.Chop), 238 | CaptureMode(TraceMode.Logic, 5, 16384, 1, 0, 1, True, BufferMode.Single), 239 | CaptureMode(TraceMode.LogicFast, 4, 4, 1, 0, 1, True, BufferMode.Single), 240 | CaptureMode(TraceMode.LogicShot, 1, 3, 1, 0, 1, True, BufferMode.Single), 241 | CaptureMode(TraceMode.Mixed, 15, 40, 16383, 1, 1, True, BufferMode.Dual), 242 | CaptureMode(TraceMode.MixedChop, 13, 40, 16383, 2, 1, True, BufferMode.ChopDual), 243 | CaptureMode(TraceMode.MixedFast, 8, 14, 1, 1, 1, True, BufferMode.Dual), 244 | CaptureMode(TraceMode.MixedFastChop, 8, 40, 1, 2, 1, True, BufferMode.ChopDual), 245 | CaptureMode(TraceMode.MixedShot, 2, 5, 1, 1, 1, True, BufferMode.Dual), 246 | CaptureMode(TraceMode.MixedShotChop, 4, 5, 1, 2, 1, True, BufferMode.ChopDual), 247 | ] 248 | 249 | 250 | class VirtualMachine: 251 | 252 | class Transaction: 253 | def __init__(self, vm): 254 | self._vm = vm 255 | self._data = b'' 256 | 257 | def append(self, cmd): 258 | self._data += cmd 259 | 260 | async def __aenter__(self): 261 | self._vm._transactions.append(self) 262 | return self 263 | 264 | async def __aexit__(self, exc_type, exc_value, traceback): 265 | if self._vm._transactions.pop() != self: 266 | raise RuntimeError("Mis-ordered transactions") 267 | if exc_type is None: 268 | await self._vm.issue(self._data) 269 | return False 270 | 271 | def __init__(self, reader=None, writer=None): 272 | self._reader = reader 273 | self._writer = writer 274 | self._transactions = [] 275 | self._reply_buffer = b'' 276 | 277 | def close(self): 278 | if self._writer is not None: 279 | self._writer.close() 280 | self._writer = None 281 | self._reader = None 282 | return True 283 | return False 284 | 285 | __del__ = close 286 | 287 | def transaction(self): 288 | return self.Transaction(self) 289 | 290 | async def issue(self, cmd): 291 | if isinstance(cmd, str): 292 | cmd = cmd.encode('ascii') 293 | if not self._transactions: 294 | Log.debug(f"Issue: {cmd!r}") 295 | self._writer.write(cmd) 296 | await self._writer.drain() 297 | echo = await self._reader.readexactly(len(cmd)) 298 | if echo != cmd: 299 | raise RuntimeError("Mismatched response") 300 | else: 301 | self._transactions[-1].append(cmd) 302 | 303 | async def read_replies(self, nreplies): 304 | if self._transactions: 305 | raise TypeError("Command transaction in progress") 306 | replies = [] 307 | data, self._reply_buffer = self._reply_buffer, b'' 308 | while len(replies) < nreplies: 309 | index = data.find(b'\r') 310 | if index >= 0: 311 | reply = data[:index] 312 | Log.debug(f"Read reply: {reply!r}") 313 | replies.append(reply) 314 | data = data[index+1:] 315 | else: 316 | data += await self._reader.read(100) 317 | if data: 318 | self._reply_buffer = data 319 | return replies 320 | 321 | async def issue_reset(self): 322 | if self._transactions: 323 | raise TypeError("Command transaction in progress") 324 | Log.debug("Issue reset") 325 | self._writer.write(b'!') 326 | await self._writer.drain() 327 | while not (await self._reader.read(1000)).endswith(b'!'): 328 | pass 329 | self._reply_buffer = b'' 330 | Log.debug("Reset complete") 331 | 332 | async def set_registers(self, **kwargs): 333 | cmd = '' 334 | register0 = register1 = None 335 | for base, name in sorted((Registers[name].base, name) for name in kwargs): 336 | register = Registers[name] 337 | data = register.encode(kwargs[name]) 338 | Log.debug(f"{name} = 0x{''.join(f'{b:02x}' for b in reversed(data))}") 339 | for i, byte in enumerate(data): 340 | if cmd: 341 | cmd += 'z' 342 | register1 += 1 343 | address = base + i 344 | if register1 is None or address > register1 + 3: 345 | cmd += f'{address:02x}@' 346 | register0 = register1 = address 347 | else: 348 | cmd += 'n' * (address - register1) 349 | register1 = address 350 | if byte != register0: 351 | cmd += '[' if byte == 0 else f'{byte:02x}' 352 | register0 = byte 353 | if cmd: 354 | await self.issue(cmd + 's') 355 | 356 | async def get_register(self, name): 357 | register = Registers[name] 358 | await self.issue(f'{register.base:02x}@p') 359 | values = [] 360 | width = register.width 361 | for i in range(width): 362 | values.append(int((await self.read_replies(2))[1], 16)) 363 | if i < width-1: 364 | await self.issue(b'np') 365 | return register.decode(bytes(values)) 366 | 367 | async def issue_get_revision(self): 368 | await self.issue(b'?') 369 | 370 | async def issue_capture_spock_registers(self): 371 | await self.issue(b'<') 372 | 373 | async def issue_program_spock_registers(self): 374 | await self.issue(b'>') 375 | 376 | async def issue_configure_device_hardware(self): 377 | await self.issue(b'U') 378 | 379 | async def issue_streaming_trace(self): 380 | await self.issue(b'T') 381 | 382 | async def issue_triggered_trace(self): 383 | await self.issue(b'D') 384 | 385 | async def read_analog_samples(self, nsamples, sample_width): 386 | if self._transactions: 387 | raise TypeError("Command transaction in progress") 388 | if sample_width == 2: 389 | data = await self._reader.readexactly(2 * nsamples) 390 | return array.array('f', ((value+32768)/65536 for (value,) in struct.iter_unpack('>h', data))) 391 | if sample_width == 1: 392 | data = await self._reader.readexactly(nsamples) 393 | return array.array('f', (value/256 for value in data)) 394 | raise ValueError(f"Bad sample width: {sample_width}") 395 | 396 | async def read_logic_samples(self, nsamples): 397 | if self._transactions: 398 | raise TypeError("Command transaction in progress") 399 | return await self._reader.readexactly(nsamples) 400 | 401 | async def issue_cancel_trace(self): 402 | await self.issue(b'K') 403 | 404 | async def issue_sample_dump_csv(self): 405 | await self.issue(b'S') 406 | 407 | async def issue_analog_dump_binary(self): 408 | await self.issue(b'A') 409 | 410 | async def issue_wavetable_read(self): 411 | await self.issue(b'R') 412 | 413 | async def wavetable_read_bytes(self, nbytes): 414 | if self._transactions: 415 | raise TypeError("Command transaction in progress") 416 | return await self._reader.readexactly(nbytes) 417 | 418 | async def wavetable_write_bytes(self, data): 419 | cmd = '' 420 | last_byte = None 421 | for byte in data: 422 | if byte != last_byte: 423 | cmd += f'{byte:02x}' 424 | cmd += 'W' 425 | last_byte = byte 426 | if cmd: 427 | await self.issue(cmd) 428 | 429 | async def issue_synthesize_wavetable(self): 430 | await self.issue(b'Y') 431 | 432 | async def issue_translate_wavetable(self): 433 | await self.issue(b'X') 434 | 435 | async def issue_control_clock_generator(self): 436 | await self.issue(b'Z') 437 | 438 | async def issue_read_eeprom(self): 439 | await self.issue(b'r') 440 | 441 | async def issue_write_eeprom(self): 442 | await self.issue(b'w') 443 | -------------------------------------------------------------------------------- /scope.py: -------------------------------------------------------------------------------- 1 | """ 2 | scope 3 | ===== 4 | 5 | Code for talking to the BitScope series of USB digital mixed-signal scopes. 6 | Only supports the BS000501 at the moment, but that's only because it's never 7 | been tested on any other model. 8 | """ 9 | 10 | # pylama:ignore=E0611,E1101,W0201,W1203,W0631,C0103,R0902,R0912,R0913,R0914,R0915,C0415,W0601,W0102 11 | 12 | import argparse 13 | import array 14 | import asyncio 15 | from collections import namedtuple 16 | from configparser import ConfigParser 17 | import logging 18 | import math 19 | from pathlib import Path 20 | import sys 21 | from urllib.parse import urlparse 22 | 23 | import streams 24 | from utils import DotDict 25 | import vm 26 | 27 | 28 | Log = logging.getLogger(__name__) 29 | AnalogParametersPath = Path('~/.config/scopething/analog.conf').expanduser() 30 | 31 | 32 | class UsageError(Exception): 33 | pass 34 | 35 | 36 | class ConfigurationError(Exception): 37 | pass 38 | 39 | 40 | class Scope(vm.VirtualMachine): 41 | 42 | class AnalogParams(namedtuple('AnalogParams', ['la', 'lb', 'lc', 'ha', 'hb', 'hc', 'scale', 'offset', 'safe_low', 'safe_high', 'ab_offset'])): 43 | def __repr__(self): 44 | return (f"la={self.la:.3f} lb={self.lb:.3e} lc={self.lc:.3e} ha={self.ha:.3f} hb={self.hb:.3e} hc={self.hc:.3e} " 45 | f"scale={self.scale:.3f}V offset={self.offset:.3f}V safe_low={self.safe_low:.2f}V safe_high={self.safe_high:.2f}V " 46 | f"ab_offset={self.ab_offset*1000:.1f}mV") 47 | 48 | async def connect(self, url=None): 49 | if url is None: 50 | for device in streams.SerialStream.devices_matching(vid=0x0403, pid=0x6001): 51 | url = f'file:{device}' 52 | break 53 | else: 54 | raise RuntimeError("No matching serial device found") 55 | self.close() 56 | Log.info(f"Connecting to scope at {url}") 57 | parts = urlparse(url, scheme='file') 58 | if parts.scheme == 'file': 59 | self._reader = self._writer = streams.SerialStream(device=parts.path) 60 | elif parts.scheme == 'socket': 61 | host, port = parts.netloc.split(':', 1) 62 | self._reader, self._writer = await asyncio.open_connection(host, int(port)) 63 | else: 64 | raise ValueError(f"Don't know what to do with url: {url}") 65 | self.url = url 66 | await self.reset() 67 | return self 68 | 69 | async def reset(self): 70 | Log.info("Resetting scope") 71 | await self.issue_reset() 72 | await self.issue_get_revision() 73 | revision = ((await self.read_replies(2))[1]).decode('ascii') 74 | if revision == 'BS000501': 75 | self.primary_clock_rate = 40000000 76 | self.primary_clock_period = 1/self.primary_clock_rate 77 | self.capture_buffer_size = 12 << 10 78 | self.awg_wavetable_size = 1024 79 | self.awg_sample_buffer_size = 1024 80 | self.awg_minimum_clock = 33 81 | self.logic_low = 0 82 | self.awg_maximum_voltage = self.clock_voltage = self.logic_high = 3.3 83 | self.analog_params = {'x1': self.AnalogParams(1.1, -.05, 0, 1.1, -.05, -.05, 18.333, -7.517, -5.5, 8, 0)} 84 | self.analog_lo_min = 0.07 85 | self.analog_hi_max = 0.88 86 | self.timeout_clock_period = (1 << 8) * self.primary_clock_period 87 | self.timestamp_rollover = (1 << 32) * self.primary_clock_period 88 | else: 89 | raise RuntimeError(f"Unsupported scope, revision: {revision}") 90 | self._awg_running = False 91 | self._clock_running = False 92 | self.load_analog_params() 93 | Log.info(f"Initialised scope, revision: {revision}") 94 | 95 | def load_analog_params(self): 96 | config = ConfigParser() 97 | config.read(AnalogParametersPath) 98 | analog_params = {} 99 | for url in config.sections(): 100 | if url == self.url: 101 | for probes in config[url]: 102 | params = self.AnalogParams(*map(float, config[url][probes].split())) 103 | analog_params[probes] = params 104 | Log.debug(f"Loading saved parameters for {probes}: {params!r}") 105 | if analog_params: 106 | self.analog_params.update(analog_params) 107 | Log.info(f"Loaded analog parameters for probes: {', '.join(analog_params.keys())}") 108 | 109 | def save_analog_params(self): 110 | Log.info("Saving analog parameters") 111 | config = ConfigParser() 112 | config.read(AnalogParametersPath) 113 | config[self.url] = {probes: ' '.join(map(str, self.analog_params[probes])) for probes in self.analog_params} 114 | parent = AnalogParametersPath.parent 115 | if not parent.is_dir(): 116 | parent.mkdir(parents=True) 117 | with open(AnalogParametersPath, 'w') as parameters_file: 118 | config.write(parameters_file) 119 | 120 | def __enter__(self): 121 | return self 122 | 123 | def __exit__(self, exc_type, exc_value, traceback): 124 | self.close() 125 | 126 | def close(self): 127 | if super().close(): 128 | Log.info("Closed scope") 129 | 130 | def calculate_lo_hi(self, low, high, params): 131 | if not isinstance(params, self.AnalogParams): 132 | params = self.AnalogParams(*list(params) + [None]*(11-len(params))) 133 | lo = (low - params.offset) / params.scale 134 | hi = (high - params.offset) / params.scale 135 | dl = params.la*lo + params.lb*hi + params.lc 136 | dh = params.ha*hi + params.hb*lo + params.hc 137 | return dl, dh 138 | 139 | async def capture(self, channels=['A'], trigger=None, trigger_level=None, trigger_type='rising', hair_trigger=False, 140 | period=1e-3, nsamples=1000, timeout=None, low=None, high=None, raw=False, trigger_position=0.25, probes='x1'): 141 | analog_channels = set() 142 | logic_channels = set() 143 | for channel in channels: 144 | channel = channel.upper() 145 | if channel in {'A', 'B'}: 146 | analog_channels.add(channel) 147 | if trigger is None: 148 | trigger = channel 149 | elif channel == 'L': 150 | logic_channels.update(range(8)) 151 | if trigger is None: 152 | trigger = {0: 1} 153 | elif channel in {'L0', 'L1', 'L2', 'L3', 'L4', 'L5', 'L6', 'L7'}: 154 | i = int(channel[1:]) 155 | logic_channels.add(i) 156 | if trigger is None: 157 | trigger = {i: 1} 158 | else: 159 | raise ValueError(f"Unrecognised channel: {channel}") 160 | if self._awg_running and 4 in logic_channels: 161 | logic_channels.remove(4) 162 | if self._clock_running and 5 in logic_channels: 163 | logic_channels.remove(5) 164 | if 'A' in analog_channels and 7 in logic_channels: 165 | logic_channels.remove(7) 166 | if 'B' in analog_channels and 6 in logic_channels: 167 | logic_channels.remove(6) 168 | analog_enable = sum(1 << (ord(channel)-ord('A')) for channel in analog_channels) 169 | logic_enable = sum(1 << channel for channel in logic_channels) 170 | 171 | for capture_mode in vm.CaptureModes: 172 | ticks = int(round(period / self.primary_clock_period / nsamples)) 173 | clock_scale = 1 174 | if capture_mode.analog_channels == len(analog_channels) and capture_mode.logic_channels == bool(logic_channels): 175 | Log.debug(f"Considering trace mode {capture_mode.trace_mode.name}...") 176 | if ticks > capture_mode.clock_high and capture_mode.clock_divide > 1: 177 | clock_scale = min(capture_mode.clock_divide, int(math.ceil(period / self.primary_clock_period / nsamples / capture_mode.clock_high))) 178 | ticks = int(round(period / self.primary_clock_period / nsamples / clock_scale)) 179 | if ticks > capture_mode.clock_low: 180 | if ticks > capture_mode.clock_high: 181 | ticks = capture_mode.clock_high 182 | Log.debug(f"- try with tick count {ticks} x {clock_scale}") 183 | else: 184 | continue 185 | elif ticks >= capture_mode.clock_low: 186 | if ticks > capture_mode.clock_high: 187 | ticks = capture_mode.clock_high 188 | Log.debug(f"- try with tick count {ticks}") 189 | else: 190 | Log.debug("- mode too slow") 191 | continue 192 | actual_nsamples = int(round(period / self.primary_clock_period / ticks / clock_scale)) 193 | if len(analog_channels) == 2: 194 | actual_nsamples -= actual_nsamples % 2 195 | buffer_width = self.capture_buffer_size // capture_mode.sample_width 196 | if logic_channels and analog_channels: 197 | buffer_width //= 2 198 | if actual_nsamples <= buffer_width: 199 | Log.debug(f"- OK; period is {actual_nsamples} samples") 200 | nsamples = actual_nsamples 201 | break 202 | Log.debug(f"- insufficient buffer space for necessary {actual_nsamples} samples") 203 | else: 204 | raise ConfigurationError("Unable to find appropriate capture mode") 205 | sample_period = ticks*clock_scale*self.primary_clock_period 206 | sample_rate = 1/sample_period 207 | if trigger_position and sample_rate > 5e6: 208 | Log.warning("Pre-trigger capture not supported above 5M samples/s; forcing trigger_position=0") 209 | trigger_position = 0 210 | 211 | if raw: 212 | analog_params = None 213 | lo, hi = low, high 214 | else: 215 | analog_params = self.analog_params[probes] 216 | if low is None: 217 | low = analog_params.safe_low if analog_channels else self.logic_low 218 | elif low < analog_params.safe_low: 219 | Log.warning(f"Voltage range is below safe minimum: {low} < {analog_params.safe_low}") 220 | if high is None: 221 | high = analog_params.safe_high if analog_channels else self.logic_high 222 | elif high > analog_params.safe_high: 223 | Log.warning(f"Voltage range is above safe maximum: {high} > {analog_params.safe_high}") 224 | lo, hi = self.calculate_lo_hi(low, high, analog_params) 225 | 226 | spock_option = vm.SpockOption.TriggerTypeHardwareComparator 227 | kitchen_sink_a = kitchen_sink_b = 0 228 | if self._awg_running: 229 | kitchen_sink_b |= vm.KitchenSinkB.WaveformGeneratorEnable 230 | if trigger == 'A' or 7 in logic_channels: 231 | kitchen_sink_a |= vm.KitchenSinkA.ChannelAComparatorEnable 232 | if trigger == 'B' or 6 in logic_channels: 233 | kitchen_sink_a |= vm.KitchenSinkA.ChannelBComparatorEnable 234 | if analog_channels: 235 | kitchen_sink_b |= vm.KitchenSinkB.AnalogFilterEnable 236 | if trigger_level is None: 237 | trigger_level = (high + low) / 2 238 | analog_trigger_level = (trigger_level - analog_params.offset) / analog_params.scale if not raw else trigger_level 239 | if isinstance(trigger, dict): 240 | trigger_logic = 0 241 | trigger_mask = 0xff 242 | for channel, value in trigger.items(): 243 | if isinstance(channel, str): 244 | if channel.startswith('L'): 245 | channel = int(channel[1:]) # noqa 246 | else: 247 | raise ValueError("Unrecognised trigger value") 248 | if channel < 0 or channel > 7: 249 | raise ValueError("Unrecognised trigger value") 250 | mask = 1 << channel 251 | trigger_mask &= ~mask 252 | if value: 253 | trigger_logic |= mask 254 | elif trigger in {'A', 'B'}: 255 | if trigger == 'A': 256 | spock_option |= vm.SpockOption.TriggerSourceA 257 | trigger_logic = 0x80 258 | elif trigger == 'B': 259 | spock_option |= vm.SpockOption.TriggerSourceB 260 | trigger_logic = 0x40 261 | trigger_mask = 0xff ^ trigger_logic 262 | else: 263 | raise ValueError("Unrecognised trigger value") 264 | trigger_type = trigger_type.lower() 265 | if trigger_type in {'falling', 'below'}: 266 | spock_option |= vm.SpockOption.TriggerInvert 267 | elif trigger_type not in {'rising', 'above'}: 268 | raise ValueError("Unrecognised trigger_type") 269 | trigger_outro = 4 if hair_trigger else 8 270 | trigger_intro = 0 if trigger_type in {'above', 'below'} else trigger_outro 271 | trigger_samples = min(max(0, int(nsamples*trigger_position)), nsamples) 272 | trace_outro = max(0, nsamples-trigger_samples-trigger_outro) 273 | trace_intro = max(0, trigger_samples-trigger_intro) 274 | if timeout is None: 275 | trigger_timeout = 0 276 | else: 277 | trigger_timeout = int(math.ceil(((trigger_intro+trigger_outro+trace_outro+2)*ticks*clock_scale*self.primary_clock_period 278 | + timeout)/self.timeout_clock_period)) 279 | if trigger_timeout > vm.Registers.Timeout.maximum_value: 280 | if timeout > 0: 281 | raise ConfigurationError("Required trigger timeout too long") 282 | raise ConfigurationError("Required trigger timeout too long, use a later trigger position") 283 | 284 | Log.info(f"Begin {('mixed' if logic_channels else 'analogue') if analog_channels else 'logic'} signal capture " 285 | f"at {sample_rate:,.0f} samples per second (trace mode {capture_mode.trace_mode.name})") 286 | async with self.transaction(): 287 | await self.set_registers(TraceMode=capture_mode.trace_mode, BufferMode=capture_mode.buffer_mode, 288 | SampleAddress=0, ClockTicks=ticks, ClockScale=clock_scale, 289 | TriggerLevel=analog_trigger_level, TriggerLogic=trigger_logic, TriggerMask=trigger_mask, 290 | TraceIntro=trace_intro, TraceOutro=trace_outro, TraceDelay=0, Timeout=trigger_timeout, 291 | TriggerIntro=trigger_intro//2, TriggerOutro=trigger_outro//2, Prelude=0, 292 | SpockOption=spock_option, ConverterLo=lo, ConverterHi=hi, 293 | KitchenSinkA=kitchen_sink_a, KitchenSinkB=kitchen_sink_b, 294 | AnalogEnable=analog_enable, DigitalEnable=logic_enable) 295 | await self.issue_program_spock_registers() 296 | await self.issue_configure_device_hardware() 297 | await self.issue_triggered_trace() 298 | while True: 299 | try: 300 | code, timestamp = (int(x, 16) for x in await self.read_replies(2)) 301 | if code != vm.TraceStatus.Wait: 302 | break 303 | except asyncio.CancelledError: 304 | await self.issue_cancel_trace() 305 | cause = {vm.TraceStatus.Done: 'trigger', vm.TraceStatus.Auto: 'timeout', vm.TraceStatus.Stop: 'cancel'}[code] 306 | start_timestamp = timestamp - nsamples*ticks*clock_scale 307 | if start_timestamp < 0: 308 | start_timestamp += 1 << 32 309 | timestamp += 1 << 32 310 | address = int((await self.read_replies(1))[0], 16) 311 | if capture_mode.analog_channels == 2: 312 | address -= address % 2 313 | 314 | traces = DotDict() 315 | 316 | timestamps = array.array('d', (i * sample_period for i in range(nsamples))) 317 | for dump_channel, channel in enumerate(sorted(analog_channels)): 318 | asamples = nsamples // len(analog_channels) 319 | async with self.transaction(): 320 | await self.set_registers(SampleAddress=(address - nsamples) % buffer_width, 321 | DumpMode=vm.DumpMode.Native if capture_mode.sample_width == 2 else vm.DumpMode.Raw, 322 | DumpChan=dump_channel, DumpCount=asamples, DumpRepeat=1, DumpSend=1, DumpSkip=0) 323 | await self.issue_program_spock_registers() 324 | await self.issue_analog_dump_binary() 325 | value_multiplier, value_offset = (1, 0) if raw else (high-low, low-analog_params.ab_offset/2*(1 if channel == 'A' else -1)) 326 | data = await self.read_analog_samples(asamples, capture_mode.sample_width) 327 | series = DotDict({'channel': channel, 328 | 'capture_start': start_timestamp * self.primary_clock_period, 329 | 'timestamps': timestamps[dump_channel::len(analog_channels)] if len(analog_channels) > 1 else timestamps, 330 | 'samples': array.array('f', (value*value_multiplier+value_offset for value in data)), 331 | 'sample_period': sample_period*len(analog_channels), 332 | 'sample_rate': sample_rate/len(analog_channels), 333 | 'cause': cause}) 334 | if cause == 'trigger' and channel == trigger: 335 | series.trigger_timestamp = series.timestamps[trigger_samples // len(analog_channels)] 336 | series.trigger_level = trigger_level 337 | series.trigger_type = trigger_type 338 | traces[channel] = series 339 | if logic_channels: 340 | async with self.transaction(): 341 | await self.set_registers(SampleAddress=(address - nsamples) % buffer_width, 342 | DumpMode=vm.DumpMode.Raw, DumpChan=128, DumpCount=nsamples, DumpRepeat=1, DumpSend=1, DumpSkip=0) 343 | await self.issue_program_spock_registers() 344 | await self.issue_analog_dump_binary() 345 | data = await self.read_logic_samples(nsamples) 346 | for i in logic_channels: 347 | mask = 1 << i 348 | channel = f'L{i}' 349 | series = DotDict({'channel': channel, 350 | 'capture_start': start_timestamp * self.primary_clock_period, 351 | 'timestamps': timestamps, 352 | 'samples': array.array('B', (1 if value & mask else 0 for value in data)), 353 | 'sample_period': sample_period, 354 | 'sample_rate': sample_rate, 355 | 'cause': cause}) 356 | if cause == 'trigger' and isinstance(trigger, dict) and i in trigger: 357 | series.trigger_timestamp = series.timestamps[trigger_samples] 358 | series.trigger_level = trigger[i] 359 | series.trigger_type = trigger_type 360 | traces[channel] = series 361 | Log.info(f"{nsamples} samples captured on {cause}, traces: {', '.join(traces)}") 362 | return traces 363 | 364 | async def start_waveform(self, frequency, waveform='sine', ratio=0.5, low=0, high=None, min_samples=50, max_error=1e-4): 365 | if self._clock_running: 366 | raise UsageError("Cannot start waveform generator while clock in use") 367 | if high is None: 368 | high = self.awg_maximum_voltage 369 | elif high < 0 or high > self.awg_maximum_voltage: 370 | raise ValueError(f"high out of range (0-{self.awg_maximum_voltage})") 371 | if low < 0 or low > high: 372 | raise ValueError("low out of range (0-high)") 373 | max_clock = min(vm.Registers.Clock.maximum_value, int(math.floor(self.primary_clock_rate / frequency / min_samples))) 374 | min_clock = max(self.awg_minimum_clock, int(math.ceil(self.primary_clock_rate / frequency / self.awg_sample_buffer_size))) 375 | best_solution = None 376 | for clock in range(min_clock, max_clock+1): 377 | width = self.primary_clock_rate / frequency / clock 378 | nwaves = int(self.awg_sample_buffer_size / width) 379 | size = int(round(nwaves * width)) 380 | actualf = self.primary_clock_rate * nwaves / size / clock 381 | if actualf == frequency: 382 | Log.debug(f"Exact solution: size={size} nwaves={nwaves} clock={clock}") 383 | break 384 | error = abs(frequency - actualf) / frequency 385 | if error < max_error and (best_solution is None or error < best_solution[0]): # noqa 386 | best_solution = error, size, nwaves, clock, actualf 387 | else: 388 | if best_solution is None: 389 | raise ConfigurationError("No solution to required frequency/min_samples/max_error") 390 | error, size, nwaves, clock, actualf = best_solution 391 | Log.debug(f"Best solution: size={size} nwaves={nwaves} clock={clock} actualf={actualf}") 392 | async with self.transaction(): 393 | if isinstance(waveform, str): 394 | mode = {'sine': 0, 'triangle': 1, 'exponential': 2, 'square': 3}[waveform.lower()] 395 | await self.set_registers(Cmd=0, Mode=mode, Ratio=ratio) 396 | await self.issue_synthesize_wavetable() 397 | elif len(waveform) == self.awg_wavetable_size: 398 | waveform = bytes(min(max(0, int(round(y*256))), 255) for y in waveform) 399 | await self.set_registers(Cmd=0, Mode=1, Address=0, Size=1) 400 | await self.wavetable_write_bytes(waveform) 401 | else: 402 | raise ValueError(f"waveform must be a valid name or a sequence of {self.awg_wavetable_size} samples [0,1)") 403 | async with self.transaction(): 404 | offset = (high+low)/2 - self.awg_maximum_voltage/2 405 | await self.set_registers(Cmd=0, Mode=0, Level=(high-low)/self.awg_maximum_voltage, 406 | Offset=offset/self.awg_maximum_voltage, 407 | Ratio=nwaves*self.awg_wavetable_size/size, 408 | Index=0, Address=0, Size=size) 409 | await self.issue_translate_wavetable() 410 | async with self.transaction(): 411 | await self.set_registers(Cmd=2, Mode=0, Clock=clock, Modulo=size, 412 | Mark=10, Space=1, Rest=0x7f00, Option=0x8004) 413 | await self.issue_control_clock_generator() 414 | async with self.transaction(): 415 | await self.set_registers(KitchenSinkB=vm.KitchenSinkB.WaveformGeneratorEnable) 416 | await self.issue_configure_device_hardware() 417 | self._awg_running = True 418 | Log.info(f"Signal generator running at {actualf:0.1f}Hz") 419 | return actualf 420 | 421 | async def stop_waveform(self): 422 | if not self._awg_running: 423 | raise UsageError("Waveform generator not in use") 424 | async with self.transaction(): 425 | await self.set_registers(Cmd=1, Mode=0) 426 | await self.issue_control_clock_generator() 427 | await self.set_registers(KitchenSinkB=0) 428 | await self.issue_configure_device_hardware() 429 | Log.info("Signal generator stopped") 430 | self._awg_running = False 431 | 432 | async def start_clock(self, frequency, ratio=0.5, max_error=1e-4): 433 | if self._awg_running: 434 | raise UsageError("Cannot start clock while waveform generator in use") 435 | ticks = min(max(2, int(round(self.primary_clock_rate / frequency))), vm.Registers.Clock.maximum_value) 436 | fall = min(max(1, int(round(ticks * ratio))), ticks-1) 437 | actualf, actualr = self.primary_clock_rate / ticks, fall / ticks 438 | if abs(actualf - frequency) / frequency > max_error: 439 | raise ConfigurationError("No solution to required frequency and max_error") 440 | async with self.transaction(): 441 | await self.set_registers(Map5=0x12, Clock=ticks, Rise=0, Fall=fall, Control=0x80, Cmd=3, Mode=0) 442 | await self.issue_control_clock_generator() 443 | self._clock_running = True 444 | Log.info(f"Clock generator running at {actualf:0.1f}Hz, {actualr*100:.0f}% duty cycle") 445 | return actualf, actualr 446 | 447 | async def stop_clock(self): 448 | if not self._clock_running: 449 | raise UsageError("Clock not in use") 450 | async with self.transaction(): 451 | await self.set_registers(Map5=0, Cmd=1, Mode=0) 452 | await self.issue_control_clock_generator() 453 | Log.info("Clock generator stopped") 454 | self._clock_running = False 455 | 456 | async def calibrate(self, probes='x1', n=32, save=True): 457 | """ 458 | Derive values for the analogue parameters based on generating a 3.3V 2kHz clock 459 | signal and then sampling the analogue channels to measure this. The first step is 460 | to set the low and high range DACs to 1/3 and 2/3, respectively. This results in 461 | *neutral* voltages matching the three series 300Ω resistances created by the ADC 462 | ladder resistance and the upper and lower bias resistors. Thus no current should 463 | be flowing in or out of the DACs and their effect on the ADC range voltages can 464 | be ignored. This allows an initial measurement to determine the full analogue 465 | range and zero offset. 466 | 467 | After this initial measurement, an `n`x`n` matrix of measurements are taken with 468 | different `lo` and `hi` DAC input values and these are used, with the known clock 469 | voltage, to reverse out the actual `low` and `high` measurement voltage range. 470 | The full set of measurements are then fed into the SciPy SLSQP minimiser to find 471 | parameters for two plane functions mapping the `low` and `high` voltages to the 472 | necessary `lo` and `hi` DAC values to achieve these. (Note that these functions 473 | are constrained to ensure that they pass through the *neutral* points. 474 | 475 | A further minimisation step is done to determine the safe analogue range based 476 | on the observed linear range of the DACs (`self.analog_lo_min` to 477 | `self.analog_hi_max`). The mean of the measured offsets between the A and B 478 | channel readings are used to determine an AB offset. 479 | """ 480 | import numpy as np 481 | from scipy.optimize import minimize 482 | items = [] 483 | 484 | async def measure(lo, hi, period=2e-3, chop=True): 485 | if chop: 486 | traces = await self.capture(channels=['A', 'B'], period=period, nsamples=2000, timeout=0, low=lo, high=hi, raw=True) 487 | A = np.array(traces.A.samples) 488 | B = np.array(traces.B.samples) 489 | else: 490 | A = np.array((await self.capture(channels=['A'], period=period/2, nsamples=1000, timeout=0, low=lo, high=hi, raw=True)).A.samples) 491 | B = np.array((await self.capture(channels=['B'], period=period/2, nsamples=1000, timeout=0, low=lo, high=hi, raw=True)).B.samples) 492 | Amean = A.mean() 493 | Azero, Afull = np.median(A[A <= Amean]), np.median(A[A >= Amean]) 494 | Bmean = B.mean() 495 | Bzero, Bfull = np.median(B[B <= Bmean]), np.median(B[B >= Bmean]) 496 | return (Azero + Bzero) / 2, (Afull + Bfull) / 2, ((Afull - Bfull) + (Azero - Azero)) / 2 497 | 498 | await self.start_clock(frequency=2000) 499 | zero, full, offset = await measure(1/3, 2/3) 500 | zero = (zero + 1) / 3 501 | full = (full + 1) / 3 502 | analog_scale = self.clock_voltage / (full - zero) 503 | analog_offset = -zero * analog_scale 504 | Log.info(f"Analog full range = {analog_scale:.2f}V, zero offset = {analog_offset:.2f}V") 505 | for lo in np.linspace(self.analog_lo_min, 0.5, n, endpoint=False): 506 | for hi in np.linspace(self.analog_hi_max, 0.5, n): 507 | zero, full, offset = await measure(lo, hi, 2e-3 if len(items) % 4 < 2 else 1e-3, len(items) % 2 == 0) 508 | if 0.01 < zero < full < 0.99: 509 | analog_range = self.clock_voltage / (full - zero) 510 | items.append((lo, hi, -zero*analog_range, (1-zero)*analog_range, offset*analog_range)) 511 | await self.stop_clock() 512 | lo, hi, low, high, offset = np.array(items).T # noqa 513 | 514 | def f(params): 515 | dl, dh = self.calculate_lo_hi(low, high, self.AnalogParams(*params, analog_scale, analog_offset, None, None, None)) 516 | return np.sqrt((lo-dl)**2 + (hi-dh)**2).mean() 517 | 518 | start_params = self.analog_params.get(probes, [1, 0, 0, 1, 0, 0])[:6] 519 | result = minimize(f, start_params, method='SLSQP', 520 | bounds=[(1, np.inf), (-np.inf, 0), (0, np.inf), (1, np.inf), (-np.inf, 0), (-np.inf, 0)], 521 | constraints=[{'type': 'eq', 'fun': lambda x: x[0]*1/3 + x[1]*2/3 + x[2] - 1/3}, 522 | {'type': 'eq', 'fun': lambda x: x[3]*2/3 + x[4]*1/3 + x[5] - 2/3}]) 523 | if result.success: 524 | Log.info(f"Calibration succeeded: {result.message}") 525 | params = self.AnalogParams(*result.x, analog_scale, analog_offset, None, None, None) 526 | 527 | def f(x): # noqa 528 | lo, hi = self.calculate_lo_hi(x[0], x[1], params) 529 | return np.sqrt((self.analog_lo_min - lo)**2 + (self.analog_hi_max - hi)**2) 530 | 531 | safe_low, safe_high = minimize(f, (low[0], high[0])).x 532 | offset_mean = offset.mean() 533 | params = self.analog_params[probes] = self.AnalogParams(*result.x, analog_scale, analog_offset, safe_low, safe_high, offset_mean) 534 | Log.info(f"{params!r} ±{100*offset.std()/offset_mean:.1f}%)") 535 | clo, chi = self.calculate_lo_hi(low, high, params) 536 | lo_error = np.sqrt((((clo-lo)/(hi-lo))**2).mean()) 537 | hi_error = np.sqrt((((chi-hi)/(hi-lo))**2).mean()) 538 | Log.info(f"Mean error: lo={lo_error*10000:.1f}bps hi={hi_error*10000:.1f}bps") 539 | if save: 540 | self.save_analog_params() 541 | else: 542 | Log.warning(f"Calibration failed: {result.message}") 543 | return result.success 544 | 545 | def __repr__(self): 546 | return f"" 547 | 548 | 549 | # $ ipython3 --pylab 550 | # Using matplotlib backend: MacOSX 551 | # 552 | # In [1]: run scope 553 | # 554 | # In [2]: start_waveform(2000, 'triangle') 555 | # Out[2]: 2000.0 556 | # 557 | # In [3]: traces = capture(['A','B'], period=1e-3, low=0, high=3.3) 558 | # 559 | # In [4]: plot(traces.A.timestamps, traces.A.samples) 560 | # Out[4]: [] 561 | # 562 | # In [5]: plot(traces.B.timestamps, traces.B.samples) 563 | # Out[5]: [] 564 | 565 | 566 | async def main(): 567 | global s 568 | parser = argparse.ArgumentParser(description="scopething") 569 | parser.add_argument('url', nargs='?', default=None, type=str, help="Device to connect to") 570 | parser.add_argument('--debug', action='store_true', default=False, help="Debug logging") 571 | parser.add_argument('--verbose', action='store_true', default=False, help="Verbose logging") 572 | args = parser.parse_args() 573 | logging.basicConfig(level=logging.DEBUG if args.debug else (logging.INFO if args.verbose else logging.WARNING), stream=sys.stdout) 574 | s = await Scope().connect(args.url) 575 | 576 | 577 | def await_(g): 578 | task = asyncio.Task(g) 579 | while True: 580 | try: 581 | return asyncio.get_event_loop().run_until_complete(task) 582 | except KeyboardInterrupt: 583 | task.cancel() 584 | 585 | 586 | def capture(*args, **kwargs): 587 | return await_(s.capture(*args, **kwargs)) 588 | 589 | 590 | def capturep(*args, **kwargs): 591 | import pandas 592 | traces = capture(*args, **kwargs) 593 | return pandas.DataFrame({channel: pandas.Series(trace.samples, trace.timestamps) for (channel, trace) in traces.items()}) 594 | 595 | 596 | def calibrate(*args, **kwargs): 597 | return await_(s.calibrate(*args, **kwargs)) 598 | 599 | 600 | def start_waveform(*args, **kwargs): 601 | return await_(s.start_waveform(*args, **kwargs)) 602 | 603 | 604 | def start_clock(*args, **kwargs): 605 | return await_(s.start_clock(*args, **kwargs)) 606 | 607 | 608 | if __name__ == '__main__': 609 | asyncio.get_event_loop().run_until_complete(main()) 610 | --------------------------------------------------------------------------------