From 504b00752a85d88d58669161779935f7bf3c1d0e Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 25 Oct 2026 12:56:54 +0200 Subject: [PATCH] feat: add 16-ring bank, sampled XOR, pair capture and frequency counter (FPGA v2) --- arduino/strix_capture/strix_capture.ino | 74 +++--- devbox.json | 6 +- fpga/rtl/entropy_gen.v | 34 --- fpga/rtl/freq_counter.v | 47 ++++ fpga/rtl/raw_capture.v | 29 ++- fpga/rtl/ring_bank.v | 36 +++ fpga/rtl/strix_core.v | 139 ++++++++--- fpga/sim/tb_strix_core.v | 158 ++++++++----- fpga/src/strix.qsf | 3 +- fpga/src/strix.sdc | 5 +- tools/capture.py | 174 -------------- tools/jitter.py | 296 ++++++++++++++++++++++++ 12 files changed, 654 insertions(+), 347 deletions(-) delete mode 100644 fpga/rtl/entropy_gen.v create mode 100644 fpga/rtl/freq_counter.v create mode 100644 fpga/rtl/ring_bank.v delete mode 100644 tools/capture.py create mode 100644 tools/jitter.py diff --git a/arduino/strix_capture/strix_capture.ino b/arduino/strix_capture/strix_capture.ino index 1aad97f..e621c29 100644 --- a/arduino/strix_capture/strix_capture.ino +++ b/arduino/strix_capture/strix_capture.ino @@ -1,10 +1,12 @@ -// STRIX raw capture tool: boots the STRIX FPGA image and serves raw ring-oscillator -// captures over USB serial for jitter characterization (see tools/capture.py). +// STRIX raw capture tool: boots the STRIX FPGA image and gives the host (tools/jitter.py) +// register access and raw captures over USB serial. // -// Commands (one per line): -// id print the FPGA identification -// cap capture a burst and send it as "DATA \n" + n raw bytes -// source: 0 tap A, 1 enhanced A, 2 enhanced B, 3 pair XOR +// Commands (one per line, numbers in decimal): +// id print the FPGA identification +// r read a register, prints its value +// w write a register, prints "OK" +// cap start a capture with the current settings and send it as +// "DATA \n" followed by n raw bytes #include #include @@ -18,17 +20,14 @@ const SPISettings STRIX_SPI_SETTINGS(8000000, MSBFIRST, SPI_MODE0); enum : uint8_t { REG_ID0 = 0x00, REG_VERSION = 0x04, - REG_SOURCE = 0x08, - REG_DECIM_HIGH = 0x09, - REG_DECIM_LOW = 0x0A, REG_CONTROL = 0x0B, REG_STATUS = 0x0C, REG_DATA = 0x10, }; const uint8_t READ = 0x80; -const uint8_t CONTROL_START = 0x01; -const uint8_t STATUS_DONE = 0x02; +const uint8_t CONTROL_START_CAPTURE = 0x01; +const uint8_t STATUS_CAPTURE_DONE = 0x02; const size_t CAPTURE_BYTES = 32768; const size_t CHUNK_BYTES = 512; @@ -66,15 +65,12 @@ void printId() { Serial.println(readRegister(REG_VERSION)); } -void capture(uint8_t source, uint16_t decimation) { - writeRegister(REG_SOURCE, source); - writeRegister(REG_DECIM_HIGH, decimation >> 8); - writeRegister(REG_DECIM_LOW, decimation & 0xFF); - writeRegister(REG_CONTROL, CONTROL_START); +void capture() { + writeRegister(REG_CONTROL, CONTROL_START_CAPTURE); + // 262,144 samples at 120 MHz take up to 143 s at the maximum decimation of 65535. unsigned long start = millis(); - while (!(readRegister(REG_STATUS) & STATUS_DONE)) { - // 262,144 samples at 120 MHz take up to 143 s at the maximum decimation of 65535. + while (!(readRegister(REG_STATUS) & STATUS_CAPTURE_DONE)) { if (millis() - start > 180000) { Serial.println("ERR timeout"); return; @@ -93,25 +89,35 @@ void capture(uint8_t source, uint16_t decimation) { } } +// Parses "" or " " after the command word; returns the number of values found. +int parseArgs(const String &args, long &a, long &b) { + int space = args.indexOf(' '); + if (args.length() == 0) return 0; + a = args.substring(0, space < 0 ? args.length() : space).toInt(); + if (space < 0) return 1; + b = args.substring(space + 1).toInt(); + return 2; +} + void handleCommand(String line) { line.trim(); - if (line == "id") { + int space = line.indexOf(' '); + String command = space < 0 ? line : line.substring(0, space); + String args = space < 0 ? "" : line.substring(space + 1); + long reg = 0, value = 0; + int count = parseArgs(args, reg, value); + + if (command == "id") { printId(); - } else if (line.startsWith("cap ")) { - int space = line.indexOf(' ', 4); - if (space < 0) { - Serial.println("ERR usage: cap "); - return; - } - long source = line.substring(4, space).toInt(); - long decimation = line.substring(space + 1).toInt(); - if (source < 0 || source > 3 || decimation < 1 || decimation > 65535) { - Serial.println("ERR source 0-3, decimation 1-65535"); - return; - } - capture(source, decimation); + } else if (command == "r" && count == 1 && reg >= 0 && reg < 0x80) { + Serial.println(readRegister(reg)); + } else if (command == "w" && count == 2 && reg >= 0 && reg < 0x80 && value >= 0 && value < 256) { + writeRegister(reg, value); + Serial.println("OK"); + } else if (command == "cap") { + capture(); } else if (line.length() > 0) { - Serial.println("ERR unknown command"); + Serial.println("ERR unknown or malformed command"); } } @@ -128,7 +134,7 @@ void setup() { pinMode(STRIX_SS, OUTPUT); digitalWrite(STRIX_SS, HIGH); strixSpi.begin(); - // SPIClass::begin() applies the pins' default mux; A3-A6 need SERCOM0's alternate mux. + // SPIClassSAMD::begin() applies the pins' default mux; A3-A6 need SERCOM0's alternate mux. pinPeripheral(A3, PIO_SERCOM_ALT); pinPeripheral(A4, PIO_SERCOM_ALT); pinPeripheral(A6, PIO_SERCOM_ALT); diff --git a/devbox.json b/devbox.json index 7e23f2a..2677082 100644 --- a/devbox.json +++ b/devbox.json @@ -23,7 +23,7 @@ ], "sim": [ "mkdir -p /tmp/strix-sim", - "iverilog -g2005 -DSIM -o /tmp/strix-sim/tb_strix_core fpga/sim/tb_strix_core.v fpga/rtl/strix_core.v fpga/rtl/entropy_gen.v fpga/rtl/ring_osc.v fpga/rtl/raw_capture.v fpga/rtl/spi_slave.v", + "iverilog -g2005 -DSIM -o /tmp/strix-sim/tb_strix_core fpga/sim/tb_strix_core.v fpga/rtl/strix_core.v fpga/rtl/ring_bank.v fpga/rtl/freq_counter.v fpga/rtl/ring_osc.v fpga/rtl/raw_capture.v fpga/rtl/spi_slave.v", "vvp -n /tmp/strix-sim/tb_strix_core" ], "fpga": [ @@ -38,8 +38,8 @@ "upload-capture": [ "arduino-cli upload --fqbn arduino:samd:mkrvidor4000 -p ${PORT:-/dev/ttyACM0} arduino/strix_capture" ], - "capture": [ - "python3 tools/capture.py --port ${PORT:-/dev/ttyACM0} --out captures" + "jitter": [ + "python3 tools/jitter.py --port ${PORT:-/dev/ttyACM0} --out captures \"$@\"" ] } } diff --git a/fpga/rtl/entropy_gen.v b/fpga/rtl/entropy_gen.v deleted file mode 100644 index 899757a..0000000 --- a/fpga/rtl/entropy_gen.v +++ /dev/null @@ -1,34 +0,0 @@ -// One generator: two rings of different lengths whose enhanced outputs are XORed, plus -// the intermediate signals exposed for jitter characterization. All four are sampled -// every clock. -module entropy_gen #( - parameter LENGTH_A = 12, - parameter LENGTH_B = 10 -) ( - input wire clk, - input wire enable, - output wire [3:0] samples // {pair XOR, enhanced B, enhanced A, single tap of A} -); - - wire ring_a_tap, ring_a_enhanced, ring_b_tap, ring_b_enhanced; - - ring_osc #(.LENGTH(LENGTH_A)) ring_a ( - .enable(enable), .first_tap(ring_a_tap), .enhanced(ring_a_enhanced) - ); - ring_osc #(.LENGTH(LENGTH_B)) ring_b ( - .enable(enable), .first_tap(ring_b_tap), .enhanced(ring_b_enhanced) - ); - - (* keep *) wire pair_xor = ring_a_enhanced ^ ring_b_enhanced; - - // sample_raw catches the asynchronous ring signals and may go metastable; - // sample_sync resolves it. The SDC file cuts timing into sample_raw. - reg [3:0] sample_raw, sample_sync; - always @(posedge clk) begin - sample_raw <= {pair_xor, ring_b_enhanced, ring_a_enhanced, ring_a_tap}; - sample_sync <= sample_raw; - end - - assign samples = sample_sync; - -endmodule diff --git a/fpga/rtl/freq_counter.v b/fpga/rtl/freq_counter.v new file mode 100644 index 0000000..d7a384f --- /dev/null +++ b/fpga/rtl/freq_counter.v @@ -0,0 +1,47 @@ +// Measures a ring frequency: an 8-bit prescaler clocked by the ring itself divides it by +// 256, and its top bit's rising edges are counted in the clk domain over a gate of +// 2^GATE_BITS clk cycles. frequency = count * 256 * f_clk / 2^GATE_BITS. +module freq_counter #( + parameter GATE_BITS = 24 // 2^24 cycles at 120 MHz = 140 ms +) ( + input wire clk, + input wire reset, + input wire signal, // asynchronous signal to measure (used as a clock) + input wire start, + output reg busy, + output reg valid, + output reg [31:0] count +); + + reg [7:0] prescaler = 8'd0; + always @(posedge signal) prescaler <= prescaler + 8'd1; + + // The SDC file cuts timing from prescaler into this synchronizer. + reg [2:0] divided_history; + always @(posedge clk) divided_history <= {divided_history[1:0], prescaler[7]}; + wire divided_rising = (divided_history[2:1] == 2'b01); + + reg [GATE_BITS-1:0] gate; + reg [31:0] edges; + + always @(posedge clk) begin + if (reset) begin + busy <= 1'b0; + valid <= 1'b0; + end else if (start) begin + busy <= 1'b1; + valid <= 1'b0; + gate <= {GATE_BITS{1'b0}}; + edges <= 32'd0; + end else if (busy) begin + gate <= gate + 1'b1; + if (divided_rising) edges <= edges + 32'd1; + if (&gate) begin + busy <= 1'b0; + valid <= 1'b1; + count <= edges + divided_rising; + end + end + end + +endmodule diff --git a/fpga/rtl/raw_capture.v b/fpga/rtl/raw_capture.v index 6d3d5f9..c44d218 100644 --- a/fpga/rtl/raw_capture.v +++ b/fpga/rtl/raw_capture.v @@ -1,12 +1,15 @@ // Captures one burst of sampled bits into block RAM for offline analysis. Keeps every -// decimation-th sample of the selected source and packs them MSB first into bytes. +// decimation-th sample and packs it MSB first into bytes: one bit per sample from +// source A, or in pair mode two bits per sample (A then B) so both sources are recorded +// at the same instants. module raw_capture #( parameter ADDR_WIDTH = 15 // 2^15 bytes = 256 Kbit per burst ) ( input wire clk, input wire reset, - input wire [3:0] samples, - input wire [1:0] source, + input wire bit_a, + input wire bit_b, + input wire pair_mode, input wire [15:0] decimation, // 0 and 1 both mean every sample input wire start, output reg busy, @@ -18,8 +21,8 @@ module raw_capture #( reg [7:0] buffer [0:(1 << ADDR_WIDTH) - 1]; reg [15:0] sample_count; - reg [2:0] bit_count; - reg [6:0] partial_byte; + reg [3:0] bit_count; + reg [7:0] partial_byte; reg [ADDR_WIDTH-1:0] next_addr; reg write_enable; reg [ADDR_WIDTH-1:0] write_addr; @@ -27,7 +30,9 @@ module raw_capture #( wire keep_sample = (sample_count == 16'd0); wire count_wraps = (decimation <= 16'd1) || (sample_count == decimation - 16'd1); - wire new_bit = samples[source]; + + wire [7:0] shifted = pair_mode ? {partial_byte[5:0], bit_a, bit_b} : {partial_byte[6:0], bit_a}; + wire [3:0] new_count = bit_count + (pair_mode ? 4'd2 : 4'd1); always @(posedge clk) begin write_enable <= 1'b0; @@ -38,22 +43,24 @@ module raw_capture #( busy <= 1'b1; done <= 1'b0; sample_count <= 16'd0; - bit_count <= 3'd0; + bit_count <= 4'd0; next_addr <= {ADDR_WIDTH{1'b0}}; end else if (busy) begin sample_count <= count_wraps ? 16'd0 : sample_count + 16'd1; if (keep_sample) begin - partial_byte <= {partial_byte[5:0], new_bit}; - bit_count <= bit_count + 3'd1; - if (bit_count == 3'd7) begin + partial_byte <= shifted; + if (new_count == 4'd8) begin + bit_count <= 4'd0; write_enable <= 1'b1; write_addr <= next_addr; - write_data <= {partial_byte, new_bit}; + write_data <= shifted; next_addr <= next_addr + 1'b1; if (&next_addr) begin busy <= 1'b0; done <= 1'b1; end + end else begin + bit_count <= new_count; end end end diff --git a/fpga/rtl/ring_bank.v b/fpga/rtl/ring_bank.v new file mode 100644 index 0000000..ddccde2 --- /dev/null +++ b/fpga/rtl/ring_bank.v @@ -0,0 +1,36 @@ +// A bank of ring oscillators. Every ring's single tap and enhanced output is synchronized +// into the clk domain separately, so all combining happens after sampling. Ring lengths +// cycle through 9, 12 and 15 stages so neighbouring rings run at different frequencies. +module ring_bank #( + parameter RINGS = 16 +) ( + input wire clk, + input wire enable, + output wire [RINGS-1:0] enhanced, // synchronized enhanced outputs + output wire [RINGS-1:0] first_tap, // synchronized single taps + output wire [RINGS-1:0] raw_tap // unsynchronized single taps, for frequency counting +); + + wire [RINGS-1:0] ring_enhanced; + + genvar i; + generate + for (i = 0; i < RINGS; i = i + 1) begin : ring + ring_osc #(.LENGTH(9 + 3 * (i % 3))) osc ( + .enable(enable), .first_tap(raw_tap[i]), .enhanced(ring_enhanced[i]) + ); + end + endgenerate + + // sample_raw catches the asynchronous ring signals and may go metastable; + // sample_sync resolves it. The SDC file cuts timing into sample_raw. + reg [2*RINGS-1:0] sample_raw, sample_sync; + always @(posedge clk) begin + sample_raw <= {raw_tap, ring_enhanced}; + sample_sync <= sample_raw; + end + + assign enhanced = sample_sync[RINGS-1:0]; + assign first_tap = sample_sync[2*RINGS-1:RINGS]; + +endmodule diff --git a/fpga/rtl/strix_core.v b/fpga/rtl/strix_core.v index 3371cf4..cb24f38 100644 --- a/fpga/rtl/strix_core.v +++ b/fpga/rtl/strix_core.v @@ -1,12 +1,17 @@ -// Board-independent STRIX logic: entropy generator, raw capture and the SPI register -// interface to the SAMD21. +// Board-independent STRIX logic: ring bank, source selection, raw capture, frequency +// counter and the SPI register interface to the SAMD21. // // SPI protocol (mode 0, one transaction per ss_n low period): the first byte is a command, // bit 7 = read (1) or write (0), bits 6:0 = register address. A write stores the following // byte(s) in that register. A read returns the register in every following byte; DATA // advances through the capture buffer with each byte, continuing across transactions. +// +// Capture sources (6-bit codes): 0x00-0x0F enhanced output of ring i, 0x10-0x1F single +// tap of ring i, 0x20-0x2F XOR of the enhanced outputs of rings 0..n-1 for n = 1..16. +// All sources are taken after sampling. module strix_core #( - parameter CAPTURE_ADDR_WIDTH = 15 + parameter CAPTURE_ADDR_WIDTH = 15, + parameter FREQ_GATE_BITS = 24 ) ( input wire clk, input wire reset, @@ -17,27 +22,62 @@ module strix_core #( output wire capture_done ); - localparam [7:0] VERSION = 8'h01; + localparam [7:0] VERSION = 8'h02; + localparam RINGS = 16; - localparam [6:0] REG_ID0 = 7'h00, // "STRX" - REG_ID1 = 7'h01, - REG_ID2 = 7'h02, - REG_ID3 = 7'h03, - REG_VERSION = 7'h04, - REG_SOURCE = 7'h08, // 0 tap A, 1 enhanced A, 2 enhanced B, 3 pair - REG_DECIM_HIGH = 7'h09, - REG_DECIM_LOW = 7'h0A, - REG_CONTROL = 7'h0B, // write: bit 0 start capture, bit 1 rewind DATA - REG_STATUS = 7'h0C, // bit 0 busy, bit 1 done - REG_DATA = 7'h10; + localparam [6:0] REG_ID0 = 7'h00, // "STRX" + REG_ID1 = 7'h01, + REG_ID2 = 7'h02, + REG_ID3 = 7'h03, + REG_VERSION = 7'h04, + REG_FREQ_GATE = 7'h05, // read-only: gate is 2^value clk cycles + REG_SOURCE_A = 7'h08, + REG_DECIM_HIGH = 7'h09, + REG_DECIM_LOW = 7'h0A, + REG_CONTROL = 7'h0B, // write: bit 0 start capture, bit 1 rewind DATA, + // bit 2 start frequency measurement + REG_STATUS = 7'h0C, // bit 0 capture busy, bit 1 capture done, + // bit 2 frequency busy, bit 3 frequency valid + REG_SOURCE_B = 7'h0D, + REG_MODE = 7'h0E, // bit 0 pair capture (A and B per sample) + REG_RING_COUNT = 7'h0F, + REG_DATA = 7'h10, + REG_FREQ_SELECT = 7'h11, // ring to measure + REG_FREQ_COUNT3 = 7'h12, // prescaled edge count, most significant byte first + REG_FREQ_COUNT2 = 7'h13, + REG_FREQ_COUNT1 = 7'h14, + REG_FREQ_COUNT0 = 7'h15; - // Entropy and capture + // Ring bank and source selection - wire [3:0] samples; + wire [RINGS-1:0] enhanced, first_tap, raw_tap; - entropy_gen generator (.clk(clk), .enable(1'b1), .samples(samples)); + ring_bank #(.RINGS(RINGS)) bank ( + .clk(clk), .enable(1'b1), + .enhanced(enhanced), .first_tap(first_tap), .raw_tap(raw_tap) + ); - reg [1:0] source; + reg [RINGS-1:0] xor_prefix_comb, xor_prefix; + integer j; + always @(*) begin + xor_prefix_comb[0] = enhanced[0]; + for (j = 1; j < RINGS; j = j + 1) + xor_prefix_comb[j] = xor_prefix_comb[j-1] ^ enhanced[j]; + end + + reg [5:0] source_a, source_b; + reg bit_a, bit_b; + wire [63:0] all_sources = {16'd0, xor_prefix, first_tap, enhanced}; + + always @(posedge clk) begin + xor_prefix <= xor_prefix_comb; + bit_a <= all_sources[source_a]; + bit_b <= all_sources[source_b]; + end + + // Capture + + reg pair_mode; reg [15:0] decimation; reg capture_start; wire capture_busy; @@ -45,12 +85,24 @@ module strix_core #( wire [7:0] capture_byte; raw_capture #(.ADDR_WIDTH(CAPTURE_ADDR_WIDTH)) capture ( - .clk(clk), .reset(reset), .samples(samples), .source(source), + .clk(clk), .reset(reset), .bit_a(bit_a), .bit_b(bit_b), .pair_mode(pair_mode), .decimation(decimation), .start(capture_start), .busy(capture_busy), .done(capture_done), .read_addr(prefetch_addr), .read_data(capture_byte) ); + // Frequency counter + + reg [3:0] freq_select; + reg freq_start; + wire freq_busy, freq_valid; + wire [31:0] freq_count; + + freq_counter #(.GATE_BITS(FREQ_GATE_BITS)) counter ( + .clk(clk), .reset(reset), .signal(raw_tap[freq_select]), .start(freq_start), + .busy(freq_busy), .valid(freq_valid), .count(freq_count) + ); + // SPI wire selected, rx_valid, rx_is_first, tx_loaded; @@ -76,25 +128,38 @@ module strix_core #( always @(*) begin case (reg_addr) - REG_ID0: tx_byte = "S"; - REG_ID1: tx_byte = "T"; - REG_ID2: tx_byte = "R"; - REG_ID3: tx_byte = "X"; - REG_VERSION: tx_byte = VERSION; - REG_SOURCE: tx_byte = {6'd0, source}; - REG_DECIM_HIGH: tx_byte = decimation[15:8]; - REG_DECIM_LOW: tx_byte = decimation[7:0]; - REG_STATUS: tx_byte = {6'd0, capture_done, capture_busy}; - REG_DATA: tx_byte = capture_byte; - default: tx_byte = 8'h00; + REG_ID0: tx_byte = "S"; + REG_ID1: tx_byte = "T"; + REG_ID2: tx_byte = "R"; + REG_ID3: tx_byte = "X"; + REG_VERSION: tx_byte = VERSION; + REG_FREQ_GATE: tx_byte = FREQ_GATE_BITS; + REG_SOURCE_A: tx_byte = {2'd0, source_a}; + REG_DECIM_HIGH: tx_byte = decimation[15:8]; + REG_DECIM_LOW: tx_byte = decimation[7:0]; + REG_STATUS: tx_byte = {4'd0, freq_valid, freq_busy, capture_done, capture_busy}; + REG_SOURCE_B: tx_byte = {2'd0, source_b}; + REG_MODE: tx_byte = {7'd0, pair_mode}; + REG_RING_COUNT: tx_byte = RINGS; + REG_DATA: tx_byte = capture_byte; + REG_FREQ_SELECT: tx_byte = {4'd0, freq_select}; + REG_FREQ_COUNT3: tx_byte = freq_count[31:24]; + REG_FREQ_COUNT2: tx_byte = freq_count[23:16]; + REG_FREQ_COUNT1: tx_byte = freq_count[15:8]; + REG_FREQ_COUNT0: tx_byte = freq_count[7:0]; + default: tx_byte = 8'h00; endcase end always @(posedge clk) begin capture_start <= 1'b0; + freq_start <= 1'b0; if (reset) begin - source <= 2'd3; + source_a <= 6'h00; + source_b <= 6'h01; + pair_mode <= 1'b0; decimation <= 16'd1; + freq_select <= 4'd0; is_read <= 1'b0; reg_addr <= 7'd0; bytes_read <= {CAPTURE_ADDR_WIDTH{1'b0}}; @@ -113,12 +178,16 @@ module strix_core #( bytes_read <= bytes_read + 1'b1; end else if (!is_read) begin case (reg_addr) - REG_SOURCE: source <= rx_byte[1:0]; - REG_DECIM_HIGH: decimation[15:8] <= rx_byte; - REG_DECIM_LOW: decimation[7:0] <= rx_byte; + REG_SOURCE_A: source_a <= rx_byte[5:0]; + REG_SOURCE_B: source_b <= rx_byte[5:0]; + REG_MODE: pair_mode <= rx_byte[0]; + REG_DECIM_HIGH: decimation[15:8] <= rx_byte; + REG_DECIM_LOW: decimation[7:0] <= rx_byte; + REG_FREQ_SELECT: freq_select <= rx_byte[3:0]; REG_CONTROL: begin if (rx_byte[0]) capture_start <= 1'b1; if (rx_byte[0] || rx_byte[1]) bytes_read <= {CAPTURE_ADDR_WIDTH{1'b0}}; + if (rx_byte[2]) freq_start <= 1'b1; end default: ; endcase diff --git a/fpga/sim/tb_strix_core.v b/fpga/sim/tb_strix_core.v index fa76755..7bbd795 100644 --- a/fpga/sim/tb_strix_core.v +++ b/fpga/sim/tb_strix_core.v @@ -1,21 +1,27 @@ -// Testbench for strix_core: SPI register access and a small raw capture read back in -// two transactions. Rings are replaced by the SIM model in ring_osc.v. +// Testbench for strix_core: SPI register access, single and pair captures read back across +// transactions, and the frequency counter. Rings are replaced by the SIM model in ring_osc.v. `timescale 1ns / 1ps module tb_strix_core; localparam CAPTURE_ADDR_WIDTH = 6; // 64-byte buffer keeps the simulation short localparam CAPTURE_BYTES = 1 << CAPTURE_ADDR_WIDTH; - localparam SCK_HALF = 62.5; // 8 MHz SCK + localparam SCK_HALF = 62.5; // 8 MHz SCK + + localparam [6:0] REG_ID0 = 7'h00, REG_ID3 = 7'h03, REG_VERSION = 7'h04, + REG_SOURCE_A = 7'h08, REG_DECIM_LOW = 7'h0A, REG_CONTROL = 7'h0B, + REG_STATUS = 7'h0C, REG_SOURCE_B = 7'h0D, REG_MODE = 7'h0E, + REG_RING_COUNT = 7'h0F, REG_DATA = 7'h10, REG_FREQ_SELECT = 7'h11, + REG_FREQ_COUNT0 = 7'h15; reg clk = 1'b0; - always #4.1667 clk = ~clk; // 120 MHz + always #4.1667 clk = ~clk; // 120 MHz reg reset = 1'b1; reg sck = 1'b0, mosi = 1'b0, ss_n = 1'b1; wire miso, done; - strix_core #(.CAPTURE_ADDR_WIDTH(CAPTURE_ADDR_WIDTH)) dut ( + strix_core #(.CAPTURE_ADDR_WIDTH(CAPTURE_ADDR_WIDTH), .FREQ_GATE_BITS(14)) dut ( .clk(clk), .reset(reset), .spi_sck(sck), .spi_mosi(mosi), .spi_ss_n(ss_n), .spi_miso(miso), .capture_done(done) @@ -55,12 +61,56 @@ module tb_strix_core; end endtask + task expect(input [7:0] got, input [7:0] want, input [8*16-1:0] what); + if (got !== want) begin + $display("FAIL %0s: got %02x, expected %02x", what, got, want); + errors = errors + 1; + end + endtask + + task run_capture; + reg [7:0] status; + integer polls; + begin + write_reg(REG_CONTROL, 8'h01); + polls = 0; + read_reg(REG_STATUS, status); + while (status[1] !== 1'b1 && polls < 1000) begin + read_reg(REG_STATUS, status); + polls = polls + 1; + end + if (status[1] !== 1'b1 || done !== 1'b1) begin + $display("FAIL capture did not finish"); + errors = errors + 1; + end + end + endtask + + // Captured bytes must be defined and vary, otherwise the read-back checks prove nothing. + task check_buffer_contents; + integer i, changes; + begin + changes = 0; + for (i = 0; i < CAPTURE_BYTES; i = i + 1) begin + if (^dut.capture.buffer[i] === 1'bx) begin + $display("FAIL buffer[%0d] undefined", i); + errors = errors + 1; + end + if (i > 0 && dut.capture.buffer[i] !== dut.capture.buffer[i - 1]) changes = changes + 1; + end + if (changes == 0) begin + $display("FAIL capture buffer is constant"); + errors = errors + 1; + end + end + endtask + task read_data(input integer first, input integer count); reg [7:0] dummy, value; integer i; begin ss_n = 1'b0; #SCK_HALF; - spi_byte({1'b1, 7'h10}, dummy); + spi_byte({1'b1, REG_DATA}, dummy); for (i = 0; i < count; i = i + 1) begin spi_byte(8'h00, value); if (value !== dut.capture.buffer[first + i]) begin @@ -72,65 +122,65 @@ module tb_strix_core; end endtask - task expect(input [7:0] got, input [7:0] want, input [8*16-1:0] what); - if (got !== want) begin - $display("FAIL %0s: got %02x, expected %02x", what, got, want); - errors = errors + 1; - end - endtask - reg [7:0] v; - integer n; + integer i, polls; initial begin #100 reset = 1'b0; #200; - read_reg(7'h00, v); expect(v, "S", "ID0"); - read_reg(7'h03, v); expect(v, "X", "ID3"); - read_reg(7'h04, v); expect(v, 8'h01, "VERSION"); + // Identification + read_reg(REG_ID0, v); expect(v, "S", "ID0"); + read_reg(REG_ID3, v); expect(v, "X", "ID3"); + read_reg(REG_VERSION, v); expect(v, 8'h02, "VERSION"); + read_reg(REG_RING_COUNT, v); expect(v, 8'd16, "RING_COUNT"); - write_reg(7'h08, 8'h01); - write_reg(7'h09, 8'h00); - write_reg(7'h0A, 8'h03); - read_reg(7'h08, v); expect(v, 8'h01, "SRC"); - read_reg(7'h0A, v); expect(v, 8'h03, "DECIM_L"); - - write_reg(7'h0B, 8'h01); - n = 0; - read_reg(7'h0C, v); - while (v[1] !== 1'b1 && n < 1000) begin - read_reg(7'h0C, v); - n = n + 1; - end - expect(v, 8'h02, "STATUS"); - if (done !== 1'b1) begin - $display("FAIL capture_done not set"); - errors = errors + 1; - end - - // Captured bytes must be defined and vary, otherwise the comparisons below prove nothing. - n = 0; - for (v = 0; v < CAPTURE_BYTES - 1; v = v + 1) begin - if (^dut.capture.buffer[v] === 1'bx) begin - $display("FAIL mem[%0d] undefined", v); - errors = errors + 1; - end - if (dut.capture.buffer[v] !== dut.capture.buffer[v + 1]) n = n + 1; - end - if (n == 0) begin - $display("FAIL capture buffer is constant"); - errors = errors + 1; - end - - // Read the buffer in two transactions; the second must continue where the first stopped. + // Single capture of the XOR of rings 0-1, every 3rd sample, read in two transactions + write_reg(REG_SOURCE_A, 8'h21); + write_reg(REG_DECIM_LOW, 8'h03); + read_reg(REG_SOURCE_A, v); expect(v, 8'h21, "SOURCE_A"); + run_capture; + check_buffer_contents; read_data(0, CAPTURE_BYTES / 2); read_data(CAPTURE_BYTES / 2, CAPTURE_BYTES / 2); - - // Rewind and read the first bytes again. - write_reg(7'h0B, 8'h02); + write_reg(REG_CONTROL, 8'h02); // rewind read_data(0, 4); + // Pair capture with A = B: every bit pair must be 00 or 11 + write_reg(REG_SOURCE_A, 8'h10); + write_reg(REG_SOURCE_B, 8'h10); + write_reg(REG_MODE, 8'h01); + write_reg(REG_DECIM_LOW, 8'h01); + run_capture; + check_buffer_contents; + for (i = 0; i < 4 * CAPTURE_BYTES; i = i + 1) begin + v = dut.capture.buffer[i / 4] >> (2 * (i % 4)); + if (v[1] !== v[0]) begin + $display("FAIL pair %0d differs", i); + errors = errors + 1; + end + end + read_data(0, CAPTURE_BYTES); + + // Frequency counter on ring 3 + write_reg(REG_FREQ_SELECT, 8'h03); + write_reg(REG_CONTROL, 8'h04); + polls = 0; + read_reg(REG_STATUS, v); + while (v[3] !== 1'b1 && polls < 1000) begin + read_reg(REG_STATUS, v); + polls = polls + 1; + end + if (v[3] !== 1'b1) begin + $display("FAIL frequency measurement did not finish"); + errors = errors + 1; + end + read_reg(REG_FREQ_COUNT0, v); + if (v == 8'd0 || dut.counter.count === 32'bx) begin + $display("FAIL frequency count is %0d", dut.counter.count); + errors = errors + 1; + end + if (errors == 0) $display("PASS"); else $display("%0d error(s)", errors); $finish; diff --git a/fpga/src/strix.qsf b/fpga/src/strix.qsf index a094e4d..88ed56d 100644 --- a/fpga/src/strix.qsf +++ b/fpga/src/strix.qsf @@ -60,7 +60,8 @@ set_global_assignment -name EDA_GENERATE_FUNCTIONAL_NETLIST OFF -section_id eda_ set_global_assignment -name VERILOG_FILE ../rtl/strix.v set_global_assignment -name VERILOG_FILE ../rtl/sys_pll.v set_global_assignment -name VERILOG_FILE ../rtl/strix_core.v -set_global_assignment -name VERILOG_FILE ../rtl/entropy_gen.v +set_global_assignment -name VERILOG_FILE ../rtl/ring_bank.v +set_global_assignment -name VERILOG_FILE ../rtl/freq_counter.v set_global_assignment -name VERILOG_FILE ../rtl/ring_osc.v set_global_assignment -name VERILOG_FILE ../rtl/raw_capture.v set_global_assignment -name VERILOG_FILE ../rtl/spi_slave.v diff --git a/fpga/src/strix.sdc b/fpga/src/strix.sdc index e7ba551..56a60e5 100644 --- a/fpga/src/strix.sdc +++ b/fpga/src/strix.sdc @@ -3,7 +3,7 @@ derive_pll_clocks derive_clock_uncertainty # Ring oscillator outputs are asynchronous by design; the first sampling stage absorbs them. -set_false_path -to [get_registers {*entropy_gen:generator|sample_raw[*]}] +set_false_path -to [get_registers {*ring_bank:bank|sample_raw[*]}] # SPI inputs are synchronized in spi_slave; MISO timing relies on the half-SCK margin. set_false_path -from [get_ports {bMKR_A[*]}] @@ -12,3 +12,6 @@ set_false_path -to [get_ports {oSAM_INT}] # NINA pass-through is purely combinational between pins. set_false_path -from [get_ports {bMKR_D[*] bWM_PIO* iWM_TX}] -to [get_ports {bMKR_D[*] bWM_PIO* oWM_RX oWM_RESET}] + +# The frequency counter's prescaler runs on a ring output; its top bit is synchronized. +set_false_path -from [get_registers {*freq_counter:counter|prescaler[*]}] diff --git a/tools/capture.py b/tools/capture.py deleted file mode 100644 index 0e2db04..0000000 --- a/tools/capture.py +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env python3 -"""Collect raw ring-oscillator captures from the STRIX capture sketch and estimate jitter. - -For each source and decimation k (keep every k-th sample at 120 MHz) the script stores the -capture, estimates the per-bit entropy from conditional block entropies, and converts it to a -predictability P and relative predictability 2P-1. Accumulated phase jitter grows with sqrt(k), -so fitting a jittered square-wave sampling model to P(k) gives the effective jitter per sample -interval, in ring cycles. The estimate is approximate: the entropy is measured from past -samples, while the model assumes the ideal phase is known. On synthetic captures with known -random-walk jitter it overestimates by a factor of about 1.2-1.3, so divide by 1.3 when sizing -the XOR combining conservatively. -""" - -import argparse -import math -import sys -from pathlib import Path - -import numpy as np -import serial - -SOURCES = {0: "tap A", 1: "enhanced A", 2: "enhanced B", 3: "pair XOR"} -DEFAULT_DECIMATIONS = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096] - - -# Board communication - -def open_board(port: str) -> serial.Serial: - board = serial.Serial(port, 115200, timeout=200) - board.reset_input_buffer() - board.write(b"id\n") - while True: - line = board.readline().decode(errors="replace").strip() - if not line: - sys.exit("no answer from the capture sketch") - if line.startswith("STRX"): - print(f"board: {line}") - return board - if line.startswith("ERR"): - sys.exit(f"board: {line}") - - -def capture(board: serial.Serial, source: int, decimation: int) -> bytes: - board.write(f"cap {source} {decimation}\n".encode()) - header = board.readline().decode(errors="replace").strip() - if not header.startswith("DATA "): - sys.exit(f"board: {header or 'timeout'}") - length = int(header.split()[1]) - data = board.read(length) - if len(data) != length: - sys.exit(f"short capture: {len(data)} of {length} bytes") - return data - - -# Statistics - -def binary_entropy(p: float) -> float: - if p <= 0.0 or p >= 1.0: - return 0.0 - return -(p * math.log2(p) + (1 - p) * math.log2(1 - p)) - - -def predictability_from_entropy(h: float) -> float: - """Inverse of the binary entropy on P >= 0.5, by bisection.""" - low, high = 0.5, 1.0 - for _ in range(60): - mid = (low + high) / 2 - if binary_entropy(mid) > h: - low = mid - else: - high = mid - return (low + high) / 2 - - -def block_entropy(bits: np.ndarray, length: int) -> float: - weights = 1 << np.arange(length) - windows = np.lib.stride_tricks.sliding_window_view(bits, length) - counts = np.bincount(windows @ weights, minlength=1 << length) - p = counts[counts > 0] / counts.sum() - return float(-(p * np.log2(p)).sum()) - - -def analyze(data: bytes, order: int) -> dict: - bits = np.unpackbits(np.frombuffer(data, dtype=np.uint8)).astype(np.int64) - signed = 2.0 * bits - 1.0 - entropy = block_entropy(bits, order + 1) - block_entropy(bits, order) - predictability = predictability_from_entropy(entropy) - return { - "ones": float(bits.mean()), - "autocorr": float(np.mean(signed[:-1] * signed[1:]) - np.mean(signed) ** 2), - "entropy": entropy, - "relative_predictability": 2 * predictability - 1, - } - - -# Jitter model: a square wave sampled at a nominal phase mu in the high half-cycle, with -# Gaussian phase noise of standard deviation sigma (in cycles). Averaging the probability of -# reading the expected level over mu gives the predictability of a sample. - -_erf = np.vectorize(math.erf) - - -def model_predictability(sigma: float) -> float: - mu = np.linspace(0.0, 0.5, 201) - x = np.arange(-math.ceil(6 * sigma) - 1, math.ceil(6 * sigma) + 2) - cdf = lambda edge: 0.5 * (1 + _erf((edge[None, :] - mu[:, None]) / (sigma * math.sqrt(2)))) - p_high = (cdf(x + 0.5) - cdf(x)).sum(axis=1) - return float(p_high.mean()) - - -def fit_jitter(decimations: list[int], relative: list[float]) -> float: - """Least-squares fit of sigma_1 in sigma_k = sigma_1 * sqrt(k), via a lookup table.""" - table_sigma = np.logspace(-5, 1, 600) - table_relative = np.array([2 * model_predictability(s) - 1 for s in table_sigma]) - ks = np.array(decimations, dtype=float) - measured = np.array(relative) - - def model(sigma_1: float) -> np.ndarray: - return np.interp(np.log(sigma_1 * np.sqrt(ks)), np.log(table_sigma), table_relative) - - candidates = np.logspace(-5, 0, 1000) - errors = [np.sum((model(s) - measured) ** 2) for s in candidates] - return float(candidates[int(np.argmin(errors))]) - - -# Main - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--port", default="/dev/ttyACM0") - parser.add_argument("--out", type=Path, default=Path("captures")) - parser.add_argument("--sources", type=int, nargs="+", default=list(SOURCES)) - parser.add_argument("--decimations", type=int, nargs="+", default=DEFAULT_DECIMATIONS) - parser.add_argument("--order", type=int, default=8, help="context bits for the entropy estimate") - parser.add_argument("--analyze-only", action="store_true", help="reuse captures in --out") - args = parser.parse_args() - - args.out.mkdir(parents=True, exist_ok=True) - board = None if args.analyze_only else open_board(args.port) - - results = {} - print(f"{'source':<11} {'k':>5} {'ones':>7} {'autocorr':>9} {'H/bit':>7} {'2P-1':>8}") - for source in args.sources: - for k in args.decimations: - path = args.out / f"src{source}_dec{k}.bin" - if board: - path.write_bytes(capture(board, source, k)) - if not path.exists(): - continue - r = analyze(path.read_bytes(), args.order) - results[(source, k)] = r - print(f"{SOURCES[source]:<11} {k:>5} {r['ones']:7.4f} {r['autocorr']:9.4f} " - f"{r['entropy']:7.4f} {r['relative_predictability']:8.5f}") - - print("\nfitted jitter per 120 MHz sample interval (ring cycles, rms):") - for source in args.sources: - # Near-random points sit on the estimator's noise floor and would bias the fit. - points = [(k, results[(source, k)]["relative_predictability"]) - for k in args.decimations - if (source, k) in results and results[(source, k)]["relative_predictability"] > 0.1] - if len(points) >= 3: - ks, rel = zip(*points) - print(f" {SOURCES[source]:<11} {fit_jitter(list(ks), list(rel)):.5f}") - - print("\ncheck: enhanced A should match (tap A)^3 in relative predictability") - for k in args.decimations: - if (0, k) in results and (1, k) in results: - tap = results[(0, k)]["relative_predictability"] - enhanced = results[(1, k)]["relative_predictability"] - print(f" k={k:<5} tap^3={tap ** 3:8.5f} enhanced={enhanced:8.5f}") - - -if __name__ == "__main__": - main() diff --git a/tools/jitter.py b/tools/jitter.py new file mode 100644 index 0000000..c0f75cb --- /dev/null +++ b/tools/jitter.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Ring-oscillator experiments against the STRIX capture sketch (arduino/strix_capture). + +Subcommands: + sweep capture sources at increasing decimation k and fit the accumulated jitter + freq measure every ring's frequency + xor capture the XOR of rings 0..n-1 for n = 1..16 and compare with the product law + pairs capture ring pairs simultaneously and test them for correlation + +Entropy per bit is estimated from conditional block entropies and converted to a +predictability P; 2P-1 is the relative predictability, which multiplies when independent +sources are XORed. The jitter fit models a square wave sampled with Gaussian phase noise +whose standard deviation grows with sqrt(k). On synthetic captures it overestimates by a +factor of about 1.2-1.3, so divide by 1.3 when sizing the XOR combining conservatively. +""" + +import argparse +import math +import sys +import time +from itertools import combinations +from pathlib import Path + +import numpy as np +import serial + +RINGS = 16 +CLOCK_HZ = 120e6 +SOURCE_ENHANCED, SOURCE_TAP, SOURCE_XOR = 0x00, 0x10, 0x20 + +REG_FREQ_GATE = 0x05 +REG_SOURCE_A, REG_DECIM_HIGH, REG_DECIM_LOW = 0x08, 0x09, 0x0A +REG_CONTROL, REG_STATUS, REG_SOURCE_B, REG_MODE = 0x0B, 0x0C, 0x0D, 0x0E +REG_FREQ_SELECT, REG_FREQ_COUNT3 = 0x11, 0x12 +CONTROL_START_FREQ = 0x04 +STATUS_FREQ_VALID = 0x08 + + +def ring_length(ring: int) -> int: + return 9 + 3 * (ring % 3) + + +def source_name(code: int) -> str: + if code >= SOURCE_XOR: + return f"xor 0-{code - SOURCE_XOR}" + if code >= SOURCE_TAP: + return f"tap {code - SOURCE_TAP}" + return f"enhanced {code}" + + +# Board communication + +class Board: + def __init__(self, port: str): + self.serial = serial.Serial(port, 115200, timeout=200) + # The sketch prints its identity once when the port opens; drain it so replies stay + # aligned with commands. + time.sleep(0.5) + self.serial.reset_input_buffer() + identity = self.command("id") + if not identity.startswith("STRX v2"): + sys.exit(f"unexpected board identity: {identity!r} (needs the v2 FPGA image)") + print(f"board: {identity}") + + def command(self, line: str) -> str: + self.serial.write(f"{line}\n".encode()) + reply = self.serial.readline().decode(errors="replace").strip() + if not reply or reply.startswith("ERR"): + sys.exit(f"board: {reply or 'timeout'} (command {line!r})") + return reply + + def read(self, register: int) -> int: + return int(self.command(f"r {register}")) + + def write(self, register: int, value: int) -> None: + self.command(f"w {register} {value}") + + def capture(self, source_a: int, decimation: int, source_b: int | None = None) -> bytes: + self.write(REG_SOURCE_A, source_a) + self.write(REG_SOURCE_B, source_b if source_b is not None else 0) + self.write(REG_MODE, 1 if source_b is not None else 0) + self.write(REG_DECIM_HIGH, decimation >> 8) + self.write(REG_DECIM_LOW, decimation & 0xFF) + header = self.command("cap") + if not header.startswith("DATA "): + sys.exit(f"board: {header}") + length = int(header.split()[1]) + data = self.serial.read(length) + if len(data) != length: + sys.exit(f"short capture: {len(data)} of {length} bytes") + return data + + def frequency(self, ring: int) -> float: + self.write(REG_FREQ_SELECT, ring) + self.write(REG_CONTROL, CONTROL_START_FREQ) + while not self.read(REG_STATUS) & STATUS_FREQ_VALID: + pass + count = 0 + for i in range(4): + count = (count << 8) | self.read(REG_FREQ_COUNT3 + i) + gate_seconds = (1 << self.read(REG_FREQ_GATE)) / CLOCK_HZ + return count * 256 / gate_seconds + + +def cached_capture(board: Board | None, path: Path, *args) -> bytes | None: + if board: + path.write_bytes(board.capture(*args)) + return path.read_bytes() if path.exists() else None + + +# Statistics + +def to_bits(data: bytes) -> np.ndarray: + return np.unpackbits(np.frombuffer(data, dtype=np.uint8)).astype(np.int64) + + +def binary_entropy(p: float) -> float: + if p <= 0.0 or p >= 1.0: + return 0.0 + return -(p * math.log2(p) + (1 - p) * math.log2(1 - p)) + + +def predictability_from_entropy(h: float) -> float: + """Inverse of the binary entropy on P >= 0.5, by bisection.""" + low, high = 0.5, 1.0 + for _ in range(60): + mid = (low + high) / 2 + if binary_entropy(mid) > h: + low = mid + else: + high = mid + return (low + high) / 2 + + +def block_entropy(bits: np.ndarray, length: int) -> float: + weights = 1 << np.arange(length) + windows = np.lib.stride_tricks.sliding_window_view(bits, length) + counts = np.bincount(windows @ weights, minlength=1 << length) + p = counts[counts > 0] / counts.sum() + return float(-(p * np.log2(p)).sum()) + + +def analyze(bits: np.ndarray, order: int) -> dict: + signed = 2.0 * bits - 1.0 + entropy = block_entropy(bits, order + 1) - block_entropy(bits, order) + return { + "ones": float(bits.mean()), + "autocorr": float(np.mean(signed[:-1] * signed[1:]) - np.mean(signed) ** 2), + "entropy": entropy, + "relative": 2 * predictability_from_entropy(entropy) - 1, + } + + +def print_header() -> None: + print(f"{'source':<12} {'k':>5} {'ones':>7} {'autocorr':>9} {'H/bit':>7} {'2P-1':>8}") + + +def print_row(name: str, k: int, r: dict) -> None: + print(f"{name:<12} {k:>5} {r['ones']:7.4f} {r['autocorr']:9.4f} {r['entropy']:7.4f} {r['relative']:8.5f}") + + +# Jitter model: a square wave sampled at a nominal phase mu in the high half-cycle, with +# Gaussian phase noise of standard deviation sigma (in cycles). Averaging the probability of +# reading the expected level over mu gives the predictability of a sample. + +_erf = np.vectorize(math.erf) + + +def model_predictability(sigma: float) -> float: + mu = np.linspace(0.0, 0.5, 201) + x = np.arange(-math.ceil(6 * sigma) - 1, math.ceil(6 * sigma) + 2) + cdf = lambda edge: 0.5 * (1 + _erf((edge[None, :] - mu[:, None]) / (sigma * math.sqrt(2)))) + return float((cdf(x + 0.5) - cdf(x)).sum(axis=1).mean()) + + +def fit_jitter(decimations: list[int], relative: list[float]) -> float: + """Least-squares fit of sigma_1 in sigma_k = sigma_1 * sqrt(k), via a lookup table.""" + table_sigma = np.logspace(-5, 1, 600) + table_relative = np.array([2 * model_predictability(s) - 1 for s in table_sigma]) + ks = np.array(decimations, dtype=float) + measured = np.array(relative) + + def model(sigma_1: float) -> np.ndarray: + return np.interp(np.log(sigma_1 * np.sqrt(ks)), np.log(table_sigma), table_relative) + + candidates = np.logspace(-5, 0, 1000) + errors = [np.sum((model(s) - measured) ** 2) for s in candidates] + return float(candidates[int(np.argmin(errors))]) + + +# Subcommands + +def run_sweep(board: Board | None, args) -> None: + results = {} + print_header() + for source in args.sources: + for k in args.decimations: + data = cached_capture(board, args.out / f"sweep_s{source:02x}_k{k}.bin", source, k) + if data is None: + continue + results[(source, k)] = r = analyze(to_bits(data), args.order) + print_row(source_name(source), k, r) + + print("\nfitted jitter per 120 MHz sample interval (ring cycles, rms):") + for source in args.sources: + # Near-random points sit on the estimator's noise floor and would bias the fit. + points = [(k, results[(source, k)]["relative"]) for k in args.decimations + if (source, k) in results and results[(source, k)]["relative"] > 0.1] + if len(points) >= 3: + ks, rel = zip(*points) + print(f" {source_name(source):<12} {fit_jitter(list(ks), list(rel)):.5f}") + + +def run_freq(board: Board | None, args) -> None: + if board is None: + sys.exit("freq needs the board") + print(f"{'ring':>4} {'length':>6} {'MHz':>9}") + for ring in range(RINGS): + print(f"{ring:>4} {ring_length(ring):>6} {board.frequency(ring) / 1e6:9.2f}") + + +def run_xor(board: Board | None, args) -> None: + single = {} + for ring in range(RINGS): + data = cached_capture(board, args.out / f"single_s{ring:02x}_k{args.k}.bin", SOURCE_ENHANCED + ring, args.k) + if data is not None: + single[ring] = analyze(to_bits(data), args.order)["relative"] + + print(f"{'n':>3} {'ones':>7} {'H/bit':>7} {'2P-1':>8} {'product':>8}") + product = 1.0 + for n in range(1, RINGS + 1): + product *= single.get(n - 1, float("nan")) + data = cached_capture(board, args.out / f"xor_n{n}_k{args.k}.bin", SOURCE_XOR + n - 1, args.k) + if data is None: + continue + r = analyze(to_bits(data), args.order) + print(f"{n:>3} {r['ones']:7.4f} {r['entropy']:7.4f} {r['relative']:8.5f} {product:8.5f}") + print("\n'product' is the prediction for independent rings: the product of the single rings' 2P-1.") + print("Measured values sit on the estimator's noise floor (about 0.03 for 256 Kbit) once it is reached.") + + +def correlation(a: np.ndarray, b: np.ndarray) -> float: + a = a - a.mean() + b = b - b.mean() + return float(np.dot(a, b) / math.sqrt(np.dot(a, a) * np.dot(b, b))) + + +def run_pairs(board: Board | None, args) -> None: + rng = np.random.default_rng(0) + pairs = list(combinations(range(RINGS), 2)) + print(f"{'pair':>7} {'corr':>8} {'null sd':>8} {'z':>7}") + flagged = 0 + for i, j in pairs: + data = cached_capture(board, args.out / f"pair_{i}_{j}_k{args.k}.bin", + SOURCE_ENHANCED + i, args.k, SOURCE_ENHANCED + j) + if data is None: + continue + bits = to_bits(data).astype(float) + a, b = bits[0::2], bits[1::2] + # Null distribution: the same two streams misaligned by large random offsets. + null = [correlation(a, np.roll(b, s)) for s in rng.integers(len(b) // 4, 3 * len(b) // 4, 20)] + c, sd = correlation(a, b), float(np.std(null)) + z = c / sd if sd > 0 else float("inf") + mark = " <-- dependent?" if abs(z) > 5 else "" + flagged += bool(mark) + print(f"{i:>3}-{j:<3} {c:8.4f} {sd:8.4f} {z:7.2f}{mark}") + print(f"\n{flagged} of {len(pairs)} pairs exceed |z| > 5") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--port", default="/dev/ttyACM0") + parser.add_argument("--out", type=Path, default=Path("captures")) + parser.add_argument("--order", type=int, default=8, help="context bits for the entropy estimate") + parser.add_argument("--analyze-only", action="store_true", help="reuse captures in --out") + sub = parser.add_subparsers(dest="command", required=True) + + sweep = sub.add_parser("sweep") + sweep.add_argument("--sources", type=lambda s: int(s, 0), nargs="+", + default=[SOURCE_TAP, SOURCE_ENHANCED, SOURCE_ENHANCED + 1, SOURCE_XOR + 1]) + sweep.add_argument("--decimations", type=int, nargs="+", + default=[1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096]) + sub.add_parser("freq") + xor = sub.add_parser("xor") + xor.add_argument("-k", type=int, default=1, help="decimation") + pairs = sub.add_parser("pairs") + pairs.add_argument("-k", type=int, default=64, help="decimation") + + args = parser.parse_args() + args.out.mkdir(parents=True, exist_ok=True) + board = None if args.analyze_only else Board(args.port) + {"sweep": run_sweep, "freq": run_freq, "xor": run_xor, "pairs": run_pairs}[args.command](board, args) + + +if __name__ == "__main__": + main()