strix/tools/jitter.py

296 lines
11 KiB
Python

#!/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()