new-sonar/gateware/sampler.py
David Lenfesty 2faf509506 gw: implement sampler controller with peak detector
Mostly untested, will need testing with simulated waveforms to validate
correctness.
2023-05-07 15:15:18 -06:00

662 lines
22 KiB
Python

from migen import *
from litex.soc.interconnect.wishbone import *
from math import log2, ceil
from typing import List
"""
Random implementation notes:
- Circular buffers can keep overwriting. We only need a setting to say how many samples to save after
trigger occurs.
- Data valid from samplers to FIFOs can simply be gated via the enable signal. Everything can just run
all the time to keep things simple
- can we correct clock skew on the sample clock via Lattice primitives? I think it's possible. I doubt it
matters. Would need significant calibration effort to even have it be accurate.
- Trigger system should wait a couple clocks after trigger acquired to disable FIFOs, just in case the
CDC sync happens a bit late for some ADC channels
Configurable parameters:
- trigger_run_len: number of samples to acquire after triggered sample (can technically be arbitrarily
large, circular buffer handles data loss, should be larger than trigger_thresh_time to make sure buffers
don't get weird)
- trigger_thresh_value: minimum peak to peak value to consider triggered
- trigger_thresh_time: minimum num samples that peak must be above threshold to count as a trigger
(trigger sample number is the first sample above the threshold value) (must be >= 1)
- trigger_decay_value: decay value to subtract from peak values to potentially reduce false triggers
- trigger_decay_period: number of samples per decay application
Implementation of trigger (psuedocode), happens every sample update:
if triggered:
if num_samples + 1 >= trigger_run:
disable_trigger()
return
num_samples += 1
return
if sample > max:
max = sample
elif sample < min:
min = sample
if (max - min) > trigger_thresh_value:
if triggered_for + 1 >= trigger_thresh_time:
triggered = True
num_samples = 0
return
triggered_for += 1
decay_wait = 0
else:
triggered_for = 0
decay_wait += 1
if trigger_decay_period == 0 or decay_wait == trigger_thresh_time:
decay_wait = 0
if (max - trigger_decay_value) > (min + trigger_decay_value):
max -= trigger_decay_value
min += trigger_decay_value
"""
class CircularBuffer(Module):
"""
Circular buffer implementation that allows users to read the entire data.
Assumptions:
- Reading values while writes are ocurring does not need to have well-defined behaviour
Implementation is largely based on Migen SyncFIFO, just tweaked to operate how I want
"""
def __init__(self, width: int, depth: int, with_wb = True) -> None:
storage = Memory(width=width, depth=depth)
self.specials += storage
ptr_width = ceil(log2(depth))
# External Signals
self.len = Signal(ptr_width) # Amount of valid data in the buffer
self.clear = Signal() # Strobe to clear memory
self.rd_addr = Signal(ptr_width)
self.rd_data = Signal(width)
self.wr_data = Signal(width)
self.wr_ready = Signal() # Output, signals buffer is ready to be written to
self.wr_valid = Signal() # Input, high when data is present to be written
wr_ptr = Signal(ptr_width)
rd_ptr = Signal(ptr_width)
empty = Signal(reset=1) # Extra signal to distinguish between full and empty condition
# Hook write input signals to memory
wr_port = storage.get_port(write_capable=True)
# Always ready to write data into memory, so hook these signals straight in
self.comb += [
wr_port.adr.eq(wr_ptr),
wr_port.dat_w.eq(self.wr_data),
wr_port.we.eq(self.wr_valid),
self.wr_ready.eq(1), # We are always ready to write data in
]
# Advance write (and potentially read)
self.sync += [
If(self.wr_valid,
# We aren't empty anymore, and we won't be until we are cleared
empty.eq(0),
# Advance write pointer
If(wr_ptr < (depth - 1),
wr_ptr.eq(wr_ptr + 1))
.Else(wr_ptr.eq(0)),
# Advance read pointer if we are full (e.g. overwrite old data)
If(~empty & (wr_ptr == rd_ptr),
If(rd_ptr < (depth - 1),
rd_ptr.eq(rd_ptr + 1))
.Else(rd_ptr.eq(0))
)
)
]
# TODO should I actually set async_read?
rd_port = storage.get_port(async_read=True)
# Set read addr so 0 starts at rd_ptr and wraps around, and connect read data up
self.comb += [
If(self.rd_addr + rd_ptr < depth,
rd_port.adr.eq(self.rd_addr + rd_ptr))
.Else(
rd_port.adr.eq(self.rd_addr - (depth - rd_ptr))
),
self.rd_data.eq(rd_port.dat_r),
]
# Export the length present
self.comb += [
If(empty, self.len.eq(0))
.Else(
If(wr_ptr > rd_ptr,
self.len.eq(wr_ptr - rd_ptr))
.Elif(wr_ptr != rd_ptr,
self.len.eq(depth - (rd_ptr - wr_ptr)))
.Else(
self.len.eq(depth)
)
),
]
# "Clear" out memory if clear is strobed
# NOTE really clear should be hooked into reset, but I'm not clear on how to do that.
# Technically there's some glitches that can happen here if we write data while clear
# is asserted, but that shouldn't happen and it's fine if it does tbh.
self.sync += If(self.clear,
wr_ptr.eq(0), rd_ptr.eq(0), empty.eq(1))
# Add wishbone bus to access data
if with_wb:
self.bus = Interface(data_width=32, adr_width=ceil(log2(depth)))
self.comb += self.rd_addr.eq(self.bus.adr)
self.sync += [
self.bus.ack.eq(0),
self.bus.dat_r.eq(0),
If(~self.bus.we & self.bus.cyc & self.bus.stb,
self.bus.ack.eq(1), self.bus.dat_r.eq(self.rd_data)),
]
from migen.genlib.cdc import PulseSynchronizer
class Sampler(Module):
def __init__(self, adc_pins: Record, sampler_clock: Signal):
# TODO remove bus
self.bus = Interface(data_width=32, adr_width=11)
# self.clock_domains.foo = ClockDomain() is how to add a new clock domain, accessible at self.foo
# Connect sampler clock domain
self.clock_domains.sample_clock = ClockDomain("sample_clock")
self.comb += self.sample_clock.clk.eq(sampler_clock)
# Hook up ADC REFCLK to sample_clock
self.comb += adc_pins.refclk.eq(sampler_clock)
# We can synchronize to the sampler clock, whenever it goes high we can
# strobe a single valid signal
synchronizer = PulseSynchronizer("sample_clock", "sys")
self.submodules += synchronizer
self.valid = Signal()
self.data = Signal(10)
self.comb += [
synchronizer.i.eq(self.sample_clock.clk),
self.valid.eq(synchronizer.o),
self.data.eq(adc_pins.data),
]
# Set config pins to constant values
self.comb += adc_pins.oen_b.eq(0) # Data pins enable
self.comb += adc_pins.standby.eq(0) # Sampling standby
self.comb += adc_pins.dfs.eq(0) # DFS (raw or two's complement)
# The only remaining pin, OTR, is an out of range status indicator
# Read directly from the data pins into the wishbone bus for now, just for bringup
self.sync += If(self.valid, self.bus.dat_r.eq(adc_pins.data))
self.sync += self.bus.ack.eq(0)
self.sync += If(self.bus.cyc & self.bus.stb, self.bus.ack.eq(1))
class PeakDetector(Module):
"""
Module to detect when peak to peak voltage is high enough to consider incoming
data to be a valid ping. Configuration is provided by setting the configuration
attributes. Do not change these settings while detector is running.
Attributes
----------
data: (input)
Data signal to use for detection
data_valid: (input)
Strobed signal that indicates value on `data` is valid to be read
enable: (input)
Enables running peak detection. De-asserting this will clear all state variables
triggered: (output)
Signal that indicates peak has been triggered. Only cleared once enable is de-asserted again
Configuration Attributes
------------------------
thresh_value:
Minimum peak to peak value considered triggered
thresh_time:
Number of consecutive samples above threshold required to consider triggered
decay_value:
Decay value to subtract from peak values to prevent false triggers
decay_period:
Number of samples between each application of decay
"""
def __init__(self, data_width: int):
# Create all state signals
min_val = Signal(data_width)
max_val = Signal(data_width)
diff = Signal(data_width)
triggered_time = Signal(32)
decay_counter = Signal(32)
# Control signals
self.data = Signal(data_width)
self.data_valid = Signal()
self.enable = Signal()
self.triggered = Signal()
# Configuration Parameters
self.thresh_value = Signal(data_width)
self.thresh_time = Signal(32)
self.decay_value = Signal(data_width)
self.decay_period = Signal(32)
self.sync += If(~self.enable,
# Reset halfway. ADCs are 0-2V, and everything should be centered at 1V, so this is approximating the initial value
min_val.eq(int(2**data_width /2)),
max_val.eq(int(2**data_width /2)),
self.triggered.eq(0),
decay_counter.eq(0),
triggered_time.eq(0),
)
# Constantly updating diff to simplify some statements
self.comb += diff.eq(max_val - min_val)
self.sync += If(self.enable & self.data_valid,
# Update maximum value
If(self.data > max_val, max_val.eq(self.data)),
# Update minimum value
If(self.data < min_val, min_val.eq(self.data)),
If(diff > self.thresh_value,
# We have met the threshold for triggering, start counting
triggered_time.eq(triggered_time + 1),
decay_counter.eq(0),
# We have triggered, so we can set the output. After this point,
# nothing we do matters until enable is de-asserted and we reset
# triggered.
If(triggered_time + 1 >= self.thresh_time, self.triggered.eq(1)))
.Else(
# We have not met the threshold, reset timer and handle decay
triggered_time.eq(0),
decay_counter.eq(decay_counter + 1),
# Decay threshold has been reached, apply decay to peaks
If(decay_counter >= self.decay_period,
decay_counter.eq(0),
# Only apply decay if the values would not overlap
If(diff >= (self.decay_value << 1),
max_val.eq(max_val - self.decay_value),
min_val.eq(min_val + self.decay_value)))
)
)
class SamplerController(Module):
"""
Sampler control
Attributes
----------
bus:
Slave wishbone bus to be connected to a higher-level bus. Has an address width set according to
the provided buffer length.
buffers:
List of FIFO buffer objects used to store sample data.
samplers:
List of sampler objects provided by user.
Registers
--------
0x00: Control Register (RW)
Bit 0 - Begin capture. Resets all FIFOs and starts the peak detector
0x01: Status Register (RO)
Bit 0 - Capture complete. Set by peak detection block and cleared by software or when
0x02: trigger_run_len (RW)
Number of samples to acquire after triggering sample.
0x03: thresh_value (RW)
Minimum peak to peak value considered triggered
0x04: thresh_time (RW)
Number of consecutive samples above threshold required to consider triggered
0x05: decay_value (RW)
Decay value to subtract from peak values to prevent false triggers
0x06: decay_period (RW)
Number of samples between each application of decay
0x1xx: BUFFER_LEN_X (RO)
Lenght of data in buffer, up to the number of samplers provided.
"""
def __init__(self, samplers: List[Sampler], buffer_len):
self.samplers = samplers
num_channels = len(samplers)
# Enables reading in samples
sample_enable = Signal()
# Pull in only one CDC sync signal
sample_ready = self.samplers[0].valid
# Generate buffers for each sampler
self.buffers = [CircularBuffer(9, buffer_len) for _ in range(num_channels)]
# Connect each buffer to each sampler
for buffer, sampler in zip(self.buffers, self.samplers):
self.comb += [
# Connect only top 9 bits to memory
buffer.wr_data.eq(sampler.data[1:]),
# Writes enter FIFO only when enabled and every clock cycle
buffer.wr_valid.eq(sample_enable & sample_ready),
]
# Each sampler gets some chunk of memory at least large enough to fit
# all of it's data, so use that as a consistent offset
sample_mem_addr_width = ceil(log2(buffer_len))
# 1 control block + number of channels used = control bits
control_block_addr_width = ceil(log2(num_channels + 1))
# Bus address width
addr_width = control_block_addr_width + sample_mem_addr_width
# "Master" bus
self.bus = Interface(data_width=32, addr_width=addr_width)
# Wishbone bus used for mapping control registers
self.control_regs_bus = Interface(data_width=32, addr_width=sample_mem_addr_width)
slaves = []
slaves.append((lambda adr: adr[sample_mem_addr_width:] == 0, self.control_regs_bus))
for i, buffer in enumerate(self.buffers):
# Connect subordinate buses of buffers to decoder
slaves.append((lambda adr: adr[sample_mem_addr_width:] == i + 1, buffer.bus))
adr = (i + 1) << sample_mem_addr_width
print(f"Sampler {i} available at 0x{adr:08x}")
self.decoder = Decoder(self.bus, slaves)
# TODO how to submodule
self.submodules.decoder = self.decoder
self.peak_detector = PeakDetector(10)
self.comb += [
# Simply enable whenever we start capturing
self.peak_detector.enable.eq(sample_enable),
# Connect to the first ADC
self.peak_detector.data.eq(self.samplers[0].data),
# Use the same criteria as the fifo buffer
self.peak_detector.data_valid.eq(sample_enable & sample_ready),
]
#### Control register logic
# Storage
control_register = Signal(32)
status_register = Signal(32)
trigger_run_len = Signal(32)
def rw_register(storage: Signal, *, read: bool = True, write: bool = True):
if read:
read = self.control_regs_bus.dat_r.eq(storage)
else:
read = self.control_regs_bus.ack.eq(0)
if write:
write = storage.eq(self.control_regs_bus.dat_w)
else:
write = self.control_regs_bus.ack.eq(0)
return If(self.control_regs_bus.we, write).Else(read)
# Handle explicit config registers
cases = {
0: rw_register(control_register),
1: rw_register(status_register, write=False),
2: rw_register(trigger_run_len),
3: rw_register(self.peak_detector.thresh_value),
4: rw_register(self.peak_detector.thresh_time),
5: rw_register(self.peak_detector.decay_value),
6: rw_register(self.peak_detector.decay_period),
"default": rw_register(None, read=False, write=False)
}
# Handle length values for each sample buffer
for i, buffer in enumerate(self.buffers):
cases.update({0x100 + i: rw_register(buffer.len, write=False)})
# Connect up control registers bus
self.sync += [
self.control_regs_bus.ack.eq(0),
If(self.control_regs_bus.cyc & self.control_regs_bus.stb,
self.control_regs_bus.ack.eq(1),
Case(self.control_regs_bus.adr, cases)),
]
# Handle the control logic
post_trigger_count = Signal(32)
self.sync += [
# Reset state whenever sampling is disabled
If(~sample_enable, post_trigger_count.eq(0)),
# Reset triggering status if we have started sampling
# (peak_detector.triggered resets if sample_enable is de-asserted, so
# this is a reliable reset mechanism)
If(sample_enable & ~self.peak_detector.triggered,
status_register[0].eq(0)),
# Keep sampling past the trigger for the configured number of samples
If(self.peak_detector.triggered & sample_enable & sample_ready,
post_trigger_count.eq(post_trigger_count + 1),
# We have sampled enough, update status and stop sampling
If(post_trigger_count + 1 >= trigger_run_len,
status_register[0].eq(1),
control_register[0].eq(0))),
]
# Update register storage
self.comb += [
sample_enable.eq(control_register[0]),
]
def fifo_testbench():
dut = CircularBuffer(9, 24)
def test_fn():
assert (yield dut.len) == 0
assert (yield dut.wr_ready) == 1
# Clock some data in, check len
data = [0xDE, 0xAD, 0xBE, 0xEF]
for b in data:
(yield dut.wr_data.eq(b))
(yield dut.wr_valid.eq(1))
yield
# Stop clocking data in
(yield dut.wr_valid.eq(0))
# Tick again because setting a value waits until the next clock...
yield
fifo_len = (yield dut.len)
assert fifo_len == 4, f"len should be 4, is {fifo_len}"
# Reset
(yield dut.clear.eq(1))
yield
(yield dut.clear.eq(0))
yield
# Len should be cleared
assert (yield dut.len) == 0
# Clock more data in than capacity, check that we can read out
# the expected data
data = [r for r in range(32)] # Yes yes I could use a generator but I want to slice it later
for b in data:
(yield dut.wr_data.eq(b))
(yield dut.wr_valid.eq(1))
yield
# One more clock
(yield dut.wr_valid.eq(0))
yield
data_len = (yield dut.len)
assert data_len == 24, f"len should be 24, is {data_len}"
out_data = []
for i in range(24):
(yield dut.rd_addr.eq(i))
yield
out_data.append((yield dut.rd_data))
assert out_data[i] == data[i + 8], f"Data mismatch at index {i}, should be {data[i+8]}, is {out_data[i]}"
# At this point, everything seems to be good, so I'm leaving more exhaustive testing
run_simulation(dut, test_fn())
def write_wishbone(bus, address, value):
# Set up bus
(yield bus.adr.eq(address))
(yield bus.dat_w.eq(value))
(yield bus.stb.eq(1))
(yield bus.cyc.eq(1))
(yield bus.we.eq(1))
yield
cycles = 0
while True:
cycles += 1
assert cycles < 5, "Write fail"
if (yield bus.ack) == 1:
# We received a response, clear out bus status and exit
(yield bus.stb.eq(0))
(yield bus.cyc.eq(0))
yield
break
else:
# Tick until we receive an ACK
yield
def read_wishbone(bus, address,):
"""Sets up a read transaction. Due to limitations of the simulation method, you have to read
from dat_r, and also tick immediately after calling"""
# Set up bus
(yield bus.adr.eq(address))
(yield bus.stb.eq(1))
(yield bus.cyc.eq(1))
(yield bus.we.eq(0))
yield
cycles = 0
while True:
cycles += 1
assert cycles < 5, "Write fail"
if (yield bus.ack) == 1:
# We received a response, clear out bus status and exit
(yield bus.stb.eq(0))
(yield bus.cyc.eq(0))
break
else:
# Tick until we receive an ACK
yield
class MockSampler(Module):
"""
Attributes
----------
All Sampler attributes by default, plus the following:
index:
Index of data to use from provided data
"""
def __init__(self, data: List[int]):
memory = Memory(width=10, depth=len(data), init=data)
self.index = Signal(ceil(log2(len(data))))
self.data = Signal(10)
self.valid = Signal()
read_port = memory.get_port(async_read=True)
self.comb += [
read_port.adr.eq(self.index),
self.data.eq(read_port.dat_r),
]
class TestSoC(Module):
def __init__(self, data):
sampler = MockSampler(data)
self.submodules.sampler = sampler
# TODO multiple mock samplers to test that functionality
self.controller = SamplerController([MockSampler(data)], 1024)
self.submodules.controller = self.controller
self.bus = self.controller.bus
def controller_test_bus_access():
dut = TestSoC([2, 3, 4, 5])
def test_fn():
yield from write_wishbone(dut.bus, 2, 0xDEADBEEF)
yield from read_wishbone(dut.bus, 2)
assert (yield dut.bus.dat_r) == 0xDEADBEEF, "Read failed!"
# TODO test writing to RO register fails
run_simulation(dut, test_fn(), vcd_name="test_bus_access.vcd")
# TODO test a couple variations on waveforms:
# Just a clean waveform, should pass normally
# Clean waveform w/ some decay
# Some waveform that decay could make not trigger (i.e. a big spike)
# Clean waveform under threshold
# Test that decay operates normally and settles back down to center value
if __name__ == "__main__":
import argparse
args = argparse.ArgumentParser()
args.add_argument("--fifo", action="store_true", help="Run FIFO tests")
args.add_argument("--controller", action="store_true", help="Run sampler tests")
args = args.parse_args()
if args.fifo:
fifo_testbench()
if args.controller:
controller_test_bus_access()