From deabe0381fd30981818783431f19c265db6686f8 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Wed, 22 Jul 2026 07:38:31 -0400 Subject: [PATCH 1/3] Everything excep the javascript --- AGENTS.md | 2 + _images/eye_diagram.svg | 975 +++++++++++++++++++++++ content/pulse_shaping.rst | 15 + figure-generating-scripts/eye_diagram.py | 50 ++ 4 files changed, 1042 insertions(+) create mode 100644 _images/eye_diagram.svg create mode 100644 figure-generating-scripts/eye_diagram.py diff --git a/AGENTS.md b/AGENTS.md index 1cd6f62e..a661808c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ Marc uses AI to help create the JavaScript mini-apps and solve issues like when - Edit the `.rst` files under `content/` and the Sphinx config/templates under the repo root. - Treat `_build/` as generated output. Do not edit it directly. +- Images (primarily in SVG format) referenced by the RST live in _images/ +- Python code used to produce images (primarily in SVG format) lives in figure-generating-scripts/ - If you change a page, also check whether image assets, scripts, or config in `_static/`, `_images/`, `conf.py`, or `Makefile` need matching updates. ## How to build locally diff --git a/_images/eye_diagram.svg b/_images/eye_diagram.svg new file mode 100644 index 00000000..92ace775 --- /dev/null +++ b/_images/eye_diagram.svg @@ -0,0 +1,975 @@ + + + + + + + + 2026-07-22T07:31:31.520824 + image/svg+xml + + + Matplotlib v3.10.9, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/content/pulse_shaping.rst b/content/pulse_shaping.rst index 5ae77cfa..413f4922 100644 --- a/content/pulse_shaping.rst +++ b/content/pulse_shaping.rst @@ -275,6 +275,21 @@ If you want to play around with this concept further, below is an interactive `P > +********************************** +Eye Diagrams +********************************** + +Now that we've built and sampled a pulse-shaped signal, there's a classic tool that lets us see the health of that entire signal at a glance: the eye diagram. The idea is simple, take the pulse-shaped signal we just made, after the receive-side matched filter, and chop it into short chunks that are each a couple symbol periods long, and overlay all of those chunks on top of each other. Because every chunk is aligned to the symbol timing, the pulses stack up and reveal a repeating pattern that, with a little imagination, looks like an eye: + +.. image:: ../_images/eye_diagram.svg + :align: center + :target: ../_images/eye_diagram.svg + :alt: An eye diagram of a BPSK signal with raised-cosine pulse shaping + +What should you notice? The signal converges to tight clusters at :math:`+1` and :math:`-1` right at the symbol instants (the dashed line marks the ideal sample time, which we have purposefully set at x=0), while spreading out and crossing in between the ideal sample time. That open region in the middle is the "eye", and its size tells you how much margin you have for timing error and noise. The *height* of the opening is your amplitude margin, i.e., how much noise the signal can tolerate before samples land on the wrong side of zero. The *width* of the opening is your timing margin, i.e., how far off the receiver's sample clock can drift before ISI starts closing the eye. A wide-open eye means an easy signal to receive; a closed or fuzzy eye means noise, ISI, or timing error is eating into your margin. This is exactly the same timing sensitivity we explored with the constellation plots above, just shown a different way. + +Everything above assumed a real signal (BPSK), where we only have to look at the I component. For complex modulations like QPSK or QAM, the I and Q components each carry their own symbols, so you draw a **separate eye diagram for I and for Q**. You'll usually see them side-by-side, and both eyes need to be open for reliable reception. + ************* OQPSK and MSK ************* diff --git a/figure-generating-scripts/eye_diagram.py b/figure-generating-scripts/eye_diagram.py new file mode 100644 index 00000000..51352677 --- /dev/null +++ b/figure-generating-scripts/eye_diagram.py @@ -0,0 +1,50 @@ +import matplotlib.pyplot as plt +import numpy as np + +np.random.seed(5) + +# BPSK + RRC pulse shaping, same style of setup as the chapter's Python exercise +num_symbols = 400 +sps = 32 +beta = 0.35 +span = 8 # filter span in symbols (each side) + +# Random BPSK symbols +symbols = np.random.randint(0, 2, num_symbols) * 2 - 1 + +# Upsample (impulses spaced by sps) +x = np.zeros(num_symbols * sps) +x[::sps] = symbols + +# Raised-cosine filter (Tx + Rx combined for this illustration) +t = np.arange(-span * sps, span * sps + 1) / sps +h = np.sinc(t) * np.cos(np.pi * beta * t) / (1 - (2 * beta * t) ** 2 + 1e-20) +h /= np.max(np.convolve(np.ones(1), h)) # keep peaks near +/-1 + +y = np.convolve(x, h, mode='same') + +# Build the eye diagram: chop the signal into 2-symbol-wide slices and overlay +span_samps = 2 * sps +n_traces = 250 +fig, ax = plt.subplots(1, 1, figsize=(8, 4)) +start = span * sps # skip filter transient +for i in range(n_traces): + s = start + i * sps + seg = y[s:s + span_samps] + if len(seg) < span_samps: + break + ax.plot(np.arange(span_samps) / sps - 1, seg, color='#1f77b4', alpha=0.15, linewidth=1, rasterized=True) + +# Mark the ideal sampling instant (center of the eye) +ax.axvline(0, color='k', linestyle='--', linewidth=1) +ax.text(0.02, 1.35, 'ideal sample time', fontsize=11) + +ax.set_xlabel('Time (symbol periods)', fontsize=12) +ax.set_ylabel('Amplitude', fontsize=12) +ax.set_xlim(-1, 1) +ax.set_ylim(-1.6, 1.6) +ax.grid(True, alpha=0.3) + +plt.tight_layout() +plt.show() +fig.savefig('../_images/eye_diagram.svg', bbox_inches='tight', dpi=100) From c33002c777f48531fc54fe6ca3b6133445a15199 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Wed, 22 Jul 2026 08:03:58 -0400 Subject: [PATCH 2/3] interactive javascript app --- _static/js/eye_diagram_app.js | 364 ++++++++++++++++++++++++++++++++++ conf.py | 1 + content/pulse_shaping.rst | 9 + 3 files changed, 374 insertions(+) create mode 100644 _static/js/eye_diagram_app.js diff --git a/_static/js/eye_diagram_app.js b/_static/js/eye_diagram_app.js new file mode 100644 index 00000000..50b702e9 --- /dev/null +++ b/_static/js/eye_diagram_app.js @@ -0,0 +1,364 @@ +// Interactive eye-diagram explorer for the Pulse Shaping chapter. +// Generates random real symbols (BPSK or 4-ASK), applies raised-cosine (or root-raised-cosine) +// pulse shaping, adds AWGN and timing jitter, then overlays short windows into a +// phosphor-style eye diagram with live eye-height/eye-width calipers. +// +// Usage in a page:
+ +function eye_diagram_app(containerId) { + const container = document.getElementById(containerId || "eyeApp") || document.body; + + // ---- inject scoped styles once (all rules are prefixed by .eye-diagram-app) ---- + if (!document.getElementById("eye-diagram-app-styles")) { + const style = document.createElement("style"); + style.id = "eye-diagram-app-styles"; + style.textContent = ` +.eye-diagram-app{--accent:#e6550d;max-width:1000px;margin:8px auto 4px;color:#222;font-family:sans-serif;} +.eye-diagram-app *{box-sizing:border-box;} +.eye-diagram-app .lab{display:grid;grid-template-columns:1fr 300px;gap:16px;align-items:start;} +@media (max-width:820px){.eye-diagram-app .lab{grid-template-columns:1fr;}} +.eye-diagram-app .screen{background:#fff;border:1px solid #ccc;border-radius:4px;padding:12px;} +.eye-diagram-app .screen-label{display:flex;justify-content:space-between;align-items:center; + font-size:12px;color:#555;margin:2px 2px 8px;} +.eye-diagram-app .screen-label .dot{width:8px;height:8px;border-radius:50%;background:#17c3b2; + display:inline-block;margin-right:6px;vertical-align:middle;} +.eye-diagram-app .canvas-holder{position:relative;border:1px solid #888;border-radius:4px;overflow:hidden;background:#070c14;line-height:0;} +.eye-diagram-app canvas{display:block;width:100%;height:auto;} +.eye-diagram-app .meters{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;margin-top:12px;} +.eye-diagram-app .meter{background:#f7f7f7;border:1px solid #ccc;border-radius:4px;padding:8px 10px;} +.eye-diagram-app .meter .k{font-size:11px;color:#666;} +.eye-diagram-app .meter .v{font-family:monospace;font-size:20px;font-weight:bold;margin-top:4px;color:#222;line-height:1;} +.eye-diagram-app .meter .v small{font-size:12px;color:#888;font-weight:normal;margin-left:2px;} +.eye-diagram-app .panel{background:#fafafa;border:1px solid #ccc;border-radius:4px;padding:14px;} +.eye-diagram-app .panel h2{font-size:13px;color:#333;margin:0 0 12px;font-weight:bold;} +.eye-diagram-app .seg{display:flex;gap:4px;margin-bottom:6px;} +.eye-diagram-app .seg button{flex:1;border:1px solid #bbb;background:#fff;color:#333; + font-family:sans-serif;font-weight:normal;font-size:13px;padding:6px 4px;border-radius:4px;cursor:pointer;} +.eye-diagram-app .seg button[aria-pressed="true"]{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:bold;} +.eye-diagram-app .seg button:hover:not([aria-pressed="true"]){background:#f0f0f0;} +.eye-diagram-app .seg-hint{font-size:12px;color:#666;line-height:1.4;margin:0 0 14px;} +.eye-diagram-app .ctrl{margin-bottom:14px;} +.eye-diagram-app .ctrl .row{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:4px;} +.eye-diagram-app .ctrl label{font-size:13px;color:#333;} +.eye-diagram-app .ctrl .val{font-family:monospace;font-size:13px;color:var(--accent);font-weight:bold;} +.eye-diagram-app .ctrl .hint{font-size:11.5px;color:#777;margin-top:4px;line-height:1.4;} +.eye-diagram-app input[type=range]{width:100%;accent-color:var(--accent);cursor:pointer;margin:2px 0;} +.eye-diagram-app .actions{display:flex;gap:8px;margin-top:4px;} +.eye-diagram-app .actions button{flex:1;font-family:sans-serif;font-weight:normal;font-size:13px;padding:8px; + border:1px solid #bbb;background:#fff;color:#333;border-radius:4px;cursor:pointer;} +.eye-diagram-app .actions button:hover{background:#f0f0f0;}`; + document.head.appendChild(style); + } + + // ---- build DOM inside the container ---- + const root = document.createElement("div"); + root.className = "eye-diagram-app"; + root.innerHTML = ` +
+
+
Transmitted baseband
+
+ +
Eye diagram · overlaid bits
+
+ +
+
Eye height
%
+
Eye width
%UI
+
+
+ + +
`; + container.appendChild(root); + + const $ = (sel) => root.querySelector(sel); + + // ================= app logic (scoped to this container) ================= + const SPS = 40, SPAN = 8, L = SPS * SPAN; // samples/bit, filter half-span (symbols), half-taps + const VMIN = -2.25, VMAX = 2.25; + const TRACES = 46; // fresh bits drawn per frame + const STROKE = 'rgba(45,212,191,0.15)'; // additive teal; dense overlaps saturate to white + const BG = [7, 12, 20]; // phosphor background + + // heatmap color LUT (density → color): dark → blue → cyan → green → yellow → red + const HEAT_LUT = (() => { + const stops = [[0, [7, 12, 20]], [0.15, [26, 30, 120]], [0.38, [0, 150, 205]], + [0.58, [0, 200, 90]], [0.78, [245, 220, 40]], [1, [235, 60, 30]]]; + const lut = new Uint8ClampedArray(256 * 3); + for (let i = 0; i < 256; i++) { + const t = i / 255; let a = stops[0], b = stops[stops.length - 1]; + for (let s = 0; s < stops.length - 1; s++) { if (t >= stops[s][0] && t <= stops[s + 1][0]) { a = stops[s]; b = stops[s + 1]; break; } } + const f = (t - a[0]) / ((b[0] - a[0]) || 1); + lut[i * 3] = a[1][0] + (b[1][0] - a[1][0]) * f; + lut[i * 3 + 1] = a[1][1] + (b[1][1] - a[1][1]) * f; + lut[i * 3 + 2] = a[1][2] + (b[1][2] - a[1][2]) * f; + } + return lut; + })(); + let heatMax = 60, heatImg = null; // running density peak + reusable output buffer + + const eye = $('#ed-eye'), strip = $('#ed-strip'); + const ex = eye.getContext('2d'), sx = strip.getContext('2d'); + const EW = eye.width, EH = eye.height, SW = strip.width, SH = strip.height; + + // offscreen "phosphor" layer holds the persistent, additively-blended traces + const phos = document.createElement('canvas'); phos.width = EW; phos.height = EH; + const px = phos.getContext('2d'); + px.fillStyle = 'rgb(' + BG.join(',') + ')'; px.fillRect(0, 0, EW, EH); + + const params = { rolloff: 0.35, ebn0: 30, jitter: 0, persist: 0.9, shape: 'rc', levels: 2, heatmap: false }; + let running = true; + let stripWave = null, stripPtr = 0, stripDirty = true; + + // ---------- math ---------- + function randn() { let u = 0, v = 0; while (!u) u = Math.random(); while (!v) v = Math.random(); + return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); } + const sinc = x => x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x); + function rcTap(x, b) { if (x === 0) return 1; + const d = 1 - (2 * b * x) * (2 * b * x); + if (Math.abs(d) < 1e-8) return (Math.PI / 4) * sinc(1 / (2 * b)); + return sinc(x) * Math.cos(Math.PI * b * x) / d; } + function rrcTap(x, b) { if (x === 0) return 1 - b + 4 * b / Math.PI; + if (Math.abs(Math.abs(x) - 1 / (4 * b)) < 1e-8) { + const a = (1 + 2 / Math.PI) * Math.sin(Math.PI / (4 * b)); + const c = (1 - 2 / Math.PI) * Math.cos(Math.PI / (4 * b)); + return (b / Math.SQRT2) * (a + c); } + const p = Math.PI * x, num = Math.sin(p * (1 - b)) + 4 * b * x * Math.cos(p * (1 + b)); + const den = p * (1 - (4 * b * x) * (4 * b * x)); return num / den; } + const ebn0Lin = () => Math.pow(10, params.ebn0 / 10); + const LEVELSETS = { 2: [-1, 1], 4: [-1, -1 / 3, 1 / 3, 1] }; + const levelArr = () => LEVELSETS[params.levels]; + function ebPerBit() { const lv = levelArr(); let es = 0; for (const a of lv) es += a * a; return (es / lv.length) / Math.log2(lv.length); } + const sigma = () => Math.sqrt(ebPerBit() / (2 * ebn0Lin())); // noise per real dimension, energy-per-bit accurate + + // ---------- pulse-shaping filter (cached) ---------- + let filt = null, filtDirty = true; + function buildFilter() { + const fn = params.shape === 'rrc' ? rrcTap : rcTap, b = params.rolloff; + const h = new Float32Array(2 * L + 1), c = fn(0, b); + for (let n = -L; n <= L; n++) h[n + L] = fn(n / SPS, b) / c; + filt = h; filtDirty = false; + } + function makeWaveform(bits, addNoise) { + if (filtDirty) buildFilter(); + const N = bits.length * SPS, out = new Float32Array(N); + for (let k = 0; k < bits.length; k++) { const a = bits[k], base = k * SPS + (SPS >> 1); + for (let n = -L; n <= L; n++) { const i = base + n; if (i >= 0 && i < N) out[i] += a * filt[n + L]; } } + if (addNoise) { const s = sigma(); for (let i = 0; i < N; i++) out[i] += randn() * s; } + return out; + } + function genSymbols(n) { const lv = levelArr(), m = lv.length, s = new Float32Array(n); for (let i = 0; i < n; i++) s[i] = lv[(Math.random() * m) | 0]; return s; } + + const mapX = t => ((t + 1) / 2) * (EW - 1); + const mapY = v => (1 - (v - VMIN) / (VMAX - VMIN)) * (EH - 1); + + // ---------- draw fresh traces onto the phosphor layer (native anti-aliased lines) ---------- + function drawTraces() { + const nT = params.levels === 4 ? 64 : TRACES; + const bits = genSymbols(nT + 2 * SPAN); + const wf = makeWaveform(bits, false); + const s = sigma(), js = params.jitter * SPS; + px.globalCompositeOperation = 'lighter'; + px.strokeStyle = STROKE; px.lineWidth = 1; px.lineJoin = 'round'; + for (let k = SPAN; k < bits.length - SPAN; k++) { + const c = k * SPS + (SPS >> 1), jsh = Math.round(js * randn()); + px.beginPath(); + for (let o = 0; o <= 2 * SPS; o++) { + let idx = c - SPS + o + jsh; if (idx < 0) idx = 0; else if (idx >= wf.length) idx = wf.length - 1; + const v = wf[idx] + randn() * s, x = mapX((o - SPS) / SPS), y = mapY(v); + if (o === 0) px.moveTo(x, y); else px.lineTo(x, y); + } + px.stroke(); + } + px.globalCompositeOperation = 'source-over'; + } + + // ---------- measurement (reads the rendered phosphor, throttled) ---------- + const wLum = (r, g, b) => 0.25 * r + 0.6 * g + 0.15 * b; + const BGL = wLum(BG[0], BG[1], BG[2]); + function measure() { + const d = px.getImageData(0, 0, EW, EH).data; + const cx = Math.round(mapX(0)), cy = Math.round(mapY(0)); + let mx = 0; for (let i = 0; i < d.length; i += 4) { const l = wLum(d[i], d[i + 1], d[i + 2]) - BGL; if (l > mx) mx = l; } + heatMax = mx; // reuse the peak density to normalize the heatmap + if (mx < 2) return { heightPct: 0, widthPct: 0, hPx: 0, wPx: 0, cx, cy }; + const thr = mx * 0.10; + const sig = (r, c) => { const i = (r * EW + c) * 4; return wLum(d[i], d[i + 1], d[i + 2]) - BGL; }; + const avgC = r => { let s = 0; for (let c = cx - 2; c <= cx + 2; c++) s += sig(r, c); return s * 0.2; }; + const avgR = c => { let s = 0; for (let r = cy - 2; r <= cy + 2; r++) s += sig(r, c); return s * 0.2; }; + let hPx = 0, wPx = 0; + if (avgC(cy) < thr) { let top = cy, bot = cy; while (top > 0 && avgC(top - 1) < thr) top--; while (bot < EH - 1 && avgC(bot + 1) < thr) bot++; hPx = bot - top; } + if (avgR(cx) < thr) { let l = cx, r = cx; while (l > 0 && avgR(l - 1) < thr) l--; while (r < EW - 1 && avgR(r + 1) < thr) r++; wPx = r - l; } + const hFrac = hPx / EH * (VMAX - VMIN); + return { heightPct: Math.max(0, hFrac / 2 * 100), widthPct: Math.max(0, wPx / EW * 2 * 100), hPx, wPx, cx, cy }; + } + + // ---------- overlays (grid, sampling line, calipers) ---------- + function cap(x, y, dir) { ex.beginPath(); + if (dir === 'h') { ex.moveTo(x - 6, y); ex.lineTo(x + 6, y); } else { ex.moveTo(x, y - 6); ex.lineTo(x, y + 6); } ex.stroke(); } + function drawOverlays(m) { + ex.save(); + ex.strokeStyle = 'rgba(120,140,175,0.14)'; ex.lineWidth = 1; + for (const t of [-1, -0.5, 0.5, 1]) { const x = mapX(t) | 0; ex.beginPath(); ex.moveTo(x + .5, 0); ex.lineTo(x + .5, EH); ex.stroke(); } + for (const v of [-1, 0, 1]) { const y = mapY(v) | 0; ex.beginPath(); ex.moveTo(0, y + .5); ex.lineTo(EW, y + .5); ex.stroke(); } + + ex.strokeStyle = 'rgba(230,85,13,0.65)'; ex.setLineDash([5, 5]); ex.lineWidth = 1.5; + ex.beginPath(); ex.moveTo(m.cx + .5, 0); ex.lineTo(m.cx + .5, EH); ex.stroke(); ex.setLineDash([]); + + if (m.hPx > 4 || m.wPx > 4) { + ex.strokeStyle = '#e6550d'; ex.lineWidth = 2; + const top = m.cy - m.hPx / 2, bot = m.cy + m.hPx / 2, l = m.cx - m.wPx / 2, r = m.cx + m.wPx / 2; + if (m.hPx > 4) { ex.beginPath(); ex.moveTo(m.cx, top); ex.lineTo(m.cx, bot); ex.stroke(); cap(m.cx, top, 'h'); cap(m.cx, bot, 'h'); } + if (m.wPx > 4) { ex.beginPath(); ex.moveTo(l, m.cy); ex.lineTo(r, m.cy); ex.stroke(); cap(l, m.cy, 'v'); cap(r, m.cy, 'v'); } + } + ex.fillStyle = 'rgba(150,166,196,0.8)'; ex.font = '11px "IBM Plex Mono",ui-monospace,monospace'; + ex.textAlign = 'center'; ex.fillText('sample here', m.cx, EH - 8); + ex.textAlign = 'left'; ex.fillText('−1 UI', 6, EH - 8); + ex.textAlign = 'right'; ex.fillText('+1 UI', EW - 6, EH - 8); + ex.restore(); + } + + // ---------- heatmap display (remap the phosphor density through a color LUT) ---------- + function applyHeatmap() { + const src = px.getImageData(0, 0, EW, EH).data; + if (!heatImg) heatImg = ex.createImageData(EW, EH); + const out = heatImg.data, norm = 1 / Math.max(heatMax, 8); + for (let i = 0; i < src.length; i += 4) { + let l = (wLum(src[i], src[i + 1], src[i + 2]) - BGL) * norm; + if (l < 0) l = 0; else if (l > 1) l = 1; + l = Math.sqrt(l); // gamma lift so faint traces are visible + const j = ((l * 255) | 0) * 3; + out[i] = HEAT_LUT[j]; out[i + 1] = HEAT_LUT[j + 1]; out[i + 2] = HEAT_LUT[j + 2]; out[i + 3] = 255; + } + ex.putImageData(heatImg, 0, 0); + } + + // ---------- render ---------- + let frameCount = 0, meas = { heightPct: 0, widthPct: 0, hPx: 0, wPx: 0, cx: Math.round(mapX(0)), cy: Math.round(mapY(0)) }; + function renderEye() { + px.globalCompositeOperation = 'source-over'; // fade previous traces (persistence) + px.fillStyle = 'rgba(' + BG[0] + ',' + BG[1] + ',' + BG[2] + ',' + (1 - params.persist).toFixed(3) + ')'; + px.fillRect(0, 0, EW, EH); + drawTraces(); + if ((frameCount++ % 6) === 0) meas = measure(); + ex.clearRect(0, 0, EW, EH); + if (params.heatmap) applyHeatmap(); else ex.drawImage(phos, 0, 0); + drawOverlays(meas); + updateMeters(meas); + } + function updateMeters(m) { + $('#ed-m-height').innerHTML = m.heightPct.toFixed(0) + '%'; + $('#ed-m-width').innerHTML = m.widthPct.toFixed(0) + '%UI'; + } + function clearPhosphor() { px.globalCompositeOperation = 'source-over'; px.fillStyle = 'rgb(' + BG.join(',') + ')'; px.fillRect(0, 0, EW, EH); } + + // ---------- transmitted-signal strip ---------- + function rebuildStrip() { stripWave = makeWaveform(genSymbols(200), true); stripPtr = 0; stripDirty = false; } + function renderStrip() { + if (stripDirty || !stripWave) rebuildStrip(); + const visible = 12 * SPS; + if (running) { stripPtr += Math.round(SPS / 6); if (stripPtr + visible >= stripWave.length) rebuildStrip(); } + sx.clearRect(0, 0, SW, SH); + sx.strokeStyle = 'rgba(120,140,175,0.10)'; sx.lineWidth = 1; + for (const v of [-1, 0, 1]) { const y = (1 - (v + 2.25) / 4.5) * SH | 0; sx.beginPath(); sx.moveTo(0, y + .5); sx.lineTo(SW, y + .5); sx.stroke(); } + sx.beginPath(); sx.lineWidth = 2; sx.strokeStyle = '#17c3b2'; sx.shadowBlur = 8; sx.shadowColor = 'rgba(23,195,178,0.6)'; + for (let i = 0; i < visible; i++) { const s = stripWave[stripPtr + i], x = i / visible * SW, y = (1 - (s + 2.25) / 4.5) * SH; + if (i === 0) sx.moveTo(x, y); else sx.lineTo(x, y); } + sx.stroke(); sx.shadowBlur = 0; + } + + function frame() { if (running) renderEye(); renderStrip(); requestAnimationFrame(frame); } + + // ---------- controls ---------- + function fill(el) { const min = +el.min, max = +el.max, v = +el.value; el.style.setProperty('--fill', ((v - min) / (max - min) * 100) + '%'); } + function bindRange(id, fmt, apply) { const el = $('#' + id), out = $('#' + id + '-v'); + const upd = () => { apply(+el.value); out.textContent = fmt(+el.value); fill(el); }; el.addEventListener('input', upd); upd(); } + bindRange('ed-rolloff', v => v.toFixed(2), v => { params.rolloff = v; filtDirty = true; stripDirty = true; }); + bindRange('ed-ebn0', v => v.toFixed(1) + ' dB', v => { params.ebn0 = v; stripDirty = true; }); + bindRange('ed-jitter', v => v.toFixed(0) + '%', v => { params.jitter = v / 100; }); + bindRange('ed-persist', v => v < 0.8 ? 'Short' : v < 0.9 ? 'Medium' : v < 0.94 ? 'Long' : 'Very long', v => { params.persist = v; }); + + $('#ed-levels').addEventListener('click', e => { + const b = e.target.closest('button'); if (!b) return; + params.levels = +b.dataset.levels; stripDirty = true; clearPhosphor(); + [...e.currentTarget.children].forEach(x => x.setAttribute('aria-pressed', x === b)); + }); + + $('#ed-shape').addEventListener('click', e => { + const b = e.target.closest('button'); if (!b) return; + params.shape = b.dataset.shape; filtDirty = true; stripDirty = true; clearPhosphor(); + [...e.currentTarget.children].forEach(x => x.setAttribute('aria-pressed', x === b)); + }); + + $('#ed-view').addEventListener('click', e => { // Lines vs Heatmap: display-only, no phosphor reset needed + const b = e.target.closest('button'); if (!b) return; + params.heatmap = b.dataset.view === 'heat'; + [...e.currentTarget.children].forEach(x => x.setAttribute('aria-pressed', x === b)); + }); + + const runBtn = $('#ed-run'); + runBtn.addEventListener('click', () => { running = !running; runBtn.textContent = running ? 'Pause' : 'Run'; + runBtn.classList.toggle('paused', !running); runBtn.setAttribute('aria-pressed', running); }); + $('#ed-reset').addEventListener('click', () => { clearPhosphor(); + const set = (id, val) => { const el = $('#' + id); el.value = val; el.dispatchEvent(new Event('input')); }; + set('ed-rolloff', 0.35); set('ed-ebn0', 30); set('ed-jitter', 0); set('ed-persist', 0.9); + root.querySelector('[data-shape="rc"]').click(); + root.querySelector('[data-levels="2"]').click(); + root.querySelector('[data-view="lines"]').click(); }); + + if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + for (let i = 0; i < 26; i++) renderEye(); + running = false; runBtn.textContent = 'Run'; runBtn.classList.add('paused'); runBtn.setAttribute('aria-pressed', false); + } + requestAnimationFrame(frame); +} diff --git a/conf.py b/conf.py index ce4ffb2c..9f18dec4 100644 --- a/conf.py +++ b/conf.py @@ -203,6 +203,7 @@ def setup(app): 'js/cyclostationary_app.js', 'js/homepage_app.js', 'js/tdoa.js', + 'js/eye_diagram_app.js', 'js/sidebar_groups.js' # we also include the index.js file from the PhasedArrayVisualizer directory in setup() above ] diff --git a/content/pulse_shaping.rst b/content/pulse_shaping.rst index 413f4922..b54f6b4d 100644 --- a/content/pulse_shaping.rst +++ b/content/pulse_shaping.rst @@ -290,6 +290,15 @@ What should you notice? The signal converges to tight clusters at :math:`+1` an Everything above assumed a real signal (BPSK), where we only have to look at the I component. For complex modulations like QPSK or QAM, the I and Q components each carry their own symbols, so you draw a **separate eye diagram for I and for Q**. You'll usually see them side-by-side, and both eyes need to be open for reliable reception. +To build intuition, try the interactive explorer below. It generates random symbols, applies raised-cosine pulse shaping, and overlays the results into a live eye diagram. Drag the sliders to add noise (lower the Eb/N0), introduce timing jitter, or change the roll-off factor, and watch the eye open and close. Notice how noise closes the eye vertically (less amplitude margin) while jitter pinches it horizontally (less timing margin), and how switching from BPSK to a 4-level signal (4-ASK, which sends two bits per symbol using four amplitudes) splits it into three smaller, stacked eyes. + +.. raw:: html + +
+ + ************* OQPSK and MSK ************* From 4388a63e81ad33e3535b03e2f96a8e017c751448 Mon Sep 17 00:00:00 2001 From: Marc Lichtman Date: Wed, 22 Jul 2026 08:31:36 -0400 Subject: [PATCH 3/3] tweaks --- _static/js/eye_diagram_app.js | 20 ++++++++++---------- content/pulse_shaping.rst | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/_static/js/eye_diagram_app.js b/_static/js/eye_diagram_app.js index 50b702e9..8905ef40 100644 --- a/_static/js/eye_diagram_app.js +++ b/_static/js/eye_diagram_app.js @@ -75,7 +75,7 @@ function eye_diagram_app(containerId) { -

BPSK sends one bit per symbol (two amplitudes). 4-ASK packs two bits into four amplitudes → three stacked eyes, each about a third the height, so it needs more Eb/N0.

+

BPSK sends one bit per symbol (two amplitudes). 4-ASK packs two bits into four amplitudes → three stacked eyes, each about a third the height, so it needs more SNR.

@@ -96,9 +96,9 @@ function eye_diagram_app(containerId) {
-
30.0 dB
- -
Signal-to-noise per bit. Lower it and Gaussian noise fattens the traces, closing the eye vertically.
+
30.0 dB
+ +
Signal-to-noise ratio in dB. Lower it and Gaussian noise fattens the traces, closing the eye vertically.
@@ -156,7 +156,7 @@ function eye_diagram_app(containerId) { const px = phos.getContext('2d'); px.fillStyle = 'rgb(' + BG.join(',') + ')'; px.fillRect(0, 0, EW, EH); - const params = { rolloff: 0.35, ebn0: 30, jitter: 0, persist: 0.9, shape: 'rc', levels: 2, heatmap: false }; + const params = { rolloff: 0.35, snr: 30, jitter: 0, persist: 0.9, shape: 'rc', levels: 2, heatmap: false }; let running = true; let stripWave = null, stripPtr = 0, stripDirty = true; @@ -175,11 +175,11 @@ function eye_diagram_app(containerId) { return (b / Math.SQRT2) * (a + c); } const p = Math.PI * x, num = Math.sin(p * (1 - b)) + 4 * b * x * Math.cos(p * (1 + b)); const den = p * (1 - (4 * b * x) * (4 * b * x)); return num / den; } - const ebn0Lin = () => Math.pow(10, params.ebn0 / 10); + const snrLin = () => Math.pow(10, params.snr / 10); const LEVELSETS = { 2: [-1, 1], 4: [-1, -1 / 3, 1 / 3, 1] }; const levelArr = () => LEVELSETS[params.levels]; - function ebPerBit() { const lv = levelArr(); let es = 0; for (const a of lv) es += a * a; return (es / lv.length) / Math.log2(lv.length); } - const sigma = () => Math.sqrt(ebPerBit() / (2 * ebn0Lin())); // noise per real dimension, energy-per-bit accurate + function meanSymPower() { const lv = levelArr(); let es = 0; for (const a of lv) es += a * a; return es / lv.length; } + const sigma = () => Math.sqrt(meanSymPower() / snrLin()); // SNR = mean symbol power / noise variance // ---------- pulse-shaping filter (cached) ---------- let filt = null, filtDirty = true; @@ -324,7 +324,7 @@ function eye_diagram_app(containerId) { function bindRange(id, fmt, apply) { const el = $('#' + id), out = $('#' + id + '-v'); const upd = () => { apply(+el.value); out.textContent = fmt(+el.value); fill(el); }; el.addEventListener('input', upd); upd(); } bindRange('ed-rolloff', v => v.toFixed(2), v => { params.rolloff = v; filtDirty = true; stripDirty = true; }); - bindRange('ed-ebn0', v => v.toFixed(1) + ' dB', v => { params.ebn0 = v; stripDirty = true; }); + bindRange('ed-snr', v => v.toFixed(1) + ' dB', v => { params.snr = v; stripDirty = true; }); bindRange('ed-jitter', v => v.toFixed(0) + '%', v => { params.jitter = v / 100; }); bindRange('ed-persist', v => v < 0.8 ? 'Short' : v < 0.9 ? 'Medium' : v < 0.94 ? 'Long' : 'Very long', v => { params.persist = v; }); @@ -351,7 +351,7 @@ function eye_diagram_app(containerId) { runBtn.classList.toggle('paused', !running); runBtn.setAttribute('aria-pressed', running); }); $('#ed-reset').addEventListener('click', () => { clearPhosphor(); const set = (id, val) => { const el = $('#' + id); el.value = val; el.dispatchEvent(new Event('input')); }; - set('ed-rolloff', 0.35); set('ed-ebn0', 30); set('ed-jitter', 0); set('ed-persist', 0.9); + set('ed-rolloff', 0.35); set('ed-snr', 30); set('ed-jitter', 0); set('ed-persist', 0.9); root.querySelector('[data-shape="rc"]').click(); root.querySelector('[data-levels="2"]').click(); root.querySelector('[data-view="lines"]').click(); }); diff --git a/content/pulse_shaping.rst b/content/pulse_shaping.rst index b54f6b4d..7d0f21e7 100644 --- a/content/pulse_shaping.rst +++ b/content/pulse_shaping.rst @@ -290,7 +290,7 @@ What should you notice? The signal converges to tight clusters at :math:`+1` an Everything above assumed a real signal (BPSK), where we only have to look at the I component. For complex modulations like QPSK or QAM, the I and Q components each carry their own symbols, so you draw a **separate eye diagram for I and for Q**. You'll usually see them side-by-side, and both eyes need to be open for reliable reception. -To build intuition, try the interactive explorer below. It generates random symbols, applies raised-cosine pulse shaping, and overlays the results into a live eye diagram. Drag the sliders to add noise (lower the Eb/N0), introduce timing jitter, or change the roll-off factor, and watch the eye open and close. Notice how noise closes the eye vertically (less amplitude margin) while jitter pinches it horizontally (less timing margin), and how switching from BPSK to a 4-level signal (4-ASK, which sends two bits per symbol using four amplitudes) splits it into three smaller, stacked eyes. +To build intuition, try the interactive explorer below. It generates random symbols, applies raised-cosine pulse shaping, and overlays the results into a live eye diagram. Drag the sliders to add noise (lower the SNR), introduce timing jitter, or change the roll-off factor, and watch the eye open and close. Notice how noise closes the eye vertically (less amplitude margin) while jitter pinches it horizontally (less timing margin), and how switching from BPSK to a 4-level signal (4-ASK, which sends two bits per symbol using four amplitudes) splits it into three smaller, stacked eyes. Note that a lower roll-off factor causes higher time domain values, but it also causes the pulse to span several symbols instead of just one or two, leading to more potential combinations of values when the subsequent pulses are summed. .. raw:: html