diff --git a/Client/udpstreamer/go.mod b/Client/udpstreamer/go.mod index 75041b4..399fb51 100644 --- a/Client/udpstreamer/go.mod +++ b/Client/udpstreamer/go.mod @@ -1,12 +1,22 @@ module udpstreamer-webui -go 1.21 +go 1.24.9 require marte2/common v0.0.0 require ( + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.1 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/parquet-go/bitpack v1.0.0 // indirect + github.com/parquet-go/jsonlite v1.0.0 // indirect + github.com/parquet-go/parquet-go v0.32.0 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/twpayne/go-geom v1.6.1 // indirect golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.38.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect ) replace marte2/common => ../../Common/Client/go diff --git a/Client/udpstreamer/go.sum b/Client/udpstreamer/go.sum index 272772f..64e19c7 100644 --- a/Client/udpstreamer/go.sum +++ b/Client/udpstreamer/go.sum @@ -1,4 +1,38 @@ +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= +github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA= +github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs= +github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU= +github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0= +github.com/parquet-go/parquet-go v0.32.0 h1:NWDqTUHfrCS4cJP/Fj2HlxvqsrVedWG3sayMkf+znzM= +github.com/parquet-go/parquet-go v0.32.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4= +github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/Client/udpstreamer/main.go b/Client/udpstreamer/main.go index 4eb9fc2..936b6cc 100644 --- a/Client/udpstreamer/main.go +++ b/Client/udpstreamer/main.go @@ -94,6 +94,7 @@ func main() { http.Handle("/", http.FileServer(http.FS(sub))) http.HandleFunc("/ws", hub.HandleWebSocket) http.HandleFunc("/api/zoom", hub.HandleZoom) + http.HandleFunc("/api/export", hub.HandleExport) http.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, buildVersion) }) diff --git a/Client/udpstreamer/static/app.js b/Client/udpstreamer/static/app.js index 778cd68..0de3ea1 100644 --- a/Client/udpstreamer/static/app.js +++ b/Client/udpstreamer/static/app.js @@ -476,14 +476,24 @@ let cursorsDirty = false; // if true, redraw all plots to update cursor lines // Rolling-window anchor used to keep cursors visually fixed while live data scrolls. let _cursorAnchorNow = null; -// Horizontal value rulers — stored in normalized division units (the shared -// y scale, -4.5…4.5) so one pair applies to every plot regardless of V/div. -const rulers = { mode: 'off', yA: null, yB: null }; +// Horizontal value rulers. The on/off toggle is global, but each plot keeps its +// own pair of normalized-division positions (rulerState), so dragging Y1 in +// one plot does not move it in the others. +const rulers = { mode: 'off', plotId: null }; +const rulerState = {}; // plotId → { yA, yB } -// Layout — [label, cssClass, cols, rows] +function getRulerState(plotId) { + if (!rulerState[plotId]) rulerState[plotId] = { yA: null, yB: null }; + return rulerState[plotId]; +} + +// Layout — [label, cssClass, cols, rows, (optional) plotCount]. +// Custom (non-uniform) layouts carry an explicit plotCount; the grid template +// and the spanning cells are defined in style.css under #plot-grid.. const LAYOUTS = [ ['1×1', 'l1x1', 1, 1], ['1×2', 'l1x2', 1, 2], ['2×1', 'l2x1', 2, 1], ['1×3', 'l1x3', 1, 3], ['3×1', 'l3x1', 3, 1], ['2×2', 'l2x2', 2, 2], ['1×4', 'l1x4', 1, 4], ['4×1', 'l4x1', 4, 1], + ['1+2', 'l1p2', 2, 2, 3], // one plot spanning the top row, two below ]; let currentLayout = 'l1x1'; let colFrs = [1]; // fractional column sizes (sum = cols) @@ -816,6 +826,7 @@ function onConfig(msg) { } buildSidebar(); buildTrigSignalSelect(); + maybeRestoreViewLate(); } /* ════════════════════════════════════════════════════════════════ @@ -925,12 +936,21 @@ function wsSend(obj) { function sendWindow() { wsSend({ type: 'setWindow', seconds: windowSec }); } -// trig.threshold is held in calibrated units. The hub's comparator runs on raw +// trig.threshold is held in calibrated units. The hub.s comparator runs on raw // samples, so invert on the way out: raw = (calibrated - offset) / scale. function sendTrigConfig() { const cal = trig.signal ? calForKey(trig.signal) : Calib.IDENTITY; + // A negative calibration gain flips the signal on screen (v_cal = v_raw·scale + // + offset with scale < 0), so a calibrated rising edge is a raw FALLING + // edge. The hub compares raw samples, so send the raw direction that matches + // the edge the user picked on the calibrated trace. + let edge = trig.edge; + if (cal.scale < 0) { + if (edge === 'rising') edge = 'falling'; + else if (edge === 'falling') edge = 'rising'; + } wsSend({ - type: 'setTrigger', signal: trig.signal, edge: trig.edge, + type: 'setTrigger', signal: trig.signal, edge: edge, threshold: Calib.invertCal(trig.threshold, cal), windowSec: trig.windowSec, prePercent: trig.prePercent, mode: trig.mode, holdoffSec: trig.holdoffSec, }); @@ -1838,15 +1858,9 @@ function drawCursorLines(u, p) { if (vNorm === null) return; const cy = u.valToPos(vNorm, 'y', true); if (cy < bbox.top || cy > bbox.top + bbox.height) return; - // Un-transform normalized value back to real units for display - // y_norm = (y_raw - offset) / divValue → y_raw = y_norm * divValue + offset - const vs = sigVScale[vsKeyFor(p.id, key)]; - let vReal = vNorm; - if (vs) { - const dv = vs._resolvedDiv || vs.divValue || 1; - const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); - vReal = vNorm * dv + ofs; - } + // Calibrated value at the cursor time, from the raw source (matches + // the hover and the cursor readouts in every display mode). + const vReal = calibratedValueAt(key, val); const tc = getSigStyle(key).color; // Diamond marker at intersection ctx.fillStyle = tc; @@ -1860,7 +1874,7 @@ function drawCursorLines(u, p) { ctx.closePath(); ctx.fill(); // Value text next to diamond (real units) - const str = Math.abs(vReal) >= 10000 ? vReal.toExponential(2) : parseFloat(vReal.toPrecision(4)).toString(); + const str = vReal === null ? '—' : (Math.abs(vReal) >= 10000 ? vReal.toExponential(2) : parseFloat(vReal.toPrecision(4)).toString()); ctx.fillStyle = tc; ctx.font = '11px monospace'; const currentAlign = ctx.textAlign; @@ -1898,6 +1912,8 @@ function rulerRawValue(p, yNorm) { // Draw the horizontal value rulers (called from the draw hook). function drawRulerLines(u, p) { if (rulers.mode !== 'on') return; + const rs = rulerState[p.id]; + if (!rs) return; const { ctx, bbox } = u; if (!bbox) return; @@ -1926,8 +1942,8 @@ function drawRulerLines(u, p) { ctx.restore(); }; - drawLine(rulers.yA, 'rgba(166,227,161,0.85)', 'Y1'); - drawLine(rulers.yB, 'rgba(243,139,168,0.85)', 'Y2'); + drawLine(rs.yA, 'rgba(166,227,161,0.85)', 'Y1'); + drawLine(rs.yB, 'rgba(243,139,168,0.85)', 'Y2'); } // Compute the rolling-window anchor ("newest common timestamp") for a plot. @@ -2133,8 +2149,9 @@ function createUPlot(p) { const rect = p.uplot.over.getBoundingClientRect(); const { min, max } = p.uplot.scales.y; const toY = val => rect.top + (1 - (val - min) / (max - min)) * rect.height; - if (rulers.yA !== null && Math.abs(clientY - toY(rulers.yA)) <= CURSOR_SNAP_PX) return 'A'; - if (rulers.yB !== null && Math.abs(clientY - toY(rulers.yB)) <= CURSOR_SNAP_PX) return 'B'; + const rs = rulerState[p.id]; + if (rs && rs.yA !== null && Math.abs(clientY - toY(rs.yA)) <= CURSOR_SNAP_PX) return 'A'; + if (rs && rs.yB !== null && Math.abs(clientY - toY(rs.yB)) <= CURSOR_SNAP_PX) return 'B'; return null; } @@ -2171,8 +2188,10 @@ function createUPlot(p) { // Set cursor position immediately on mousedown if (yTarget) { - if (yTarget === 'A') rulers.yA = _rulerValFromEvent(e); - else rulers.yB = _rulerValFromEvent(e); + rulers.plotId = p.id; // the readout follows the plot whose rulers moved + const rs = getRulerState(p.id); + if (yTarget === 'A') rs.yA = _rulerValFromEvent(e); + else rs.yB = _rulerValFromEvent(e); } else if (target === 'A') cursors.tA = _cursorValFromEvent(e); else cursors.tB = _cursorValFromEvent(e); updateCursorReadout(); @@ -2180,8 +2199,9 @@ function createUPlot(p) { const onMove = ev => { if (yTarget) { - if (yTarget === 'A') rulers.yA = _rulerValFromEvent(ev); - else rulers.yB = _rulerValFromEvent(ev); + const rs = getRulerState(p.id); + if (yTarget === 'A') rs.yA = _rulerValFromEvent(ev); + else rs.yB = _rulerValFromEvent(ev); } else if (target === 'A') cursors.tA = _cursorValFromEvent(ev); else cursors.tB = _cursorValFromEvent(ev); updateCursorReadout(); @@ -2753,7 +2773,19 @@ function updateCursorBtnVisibility() { under one — so a zoom, a pan or a new capture can leave them outside the viewport entirely, with no way to get them back: they are dragged by grabbing their line, and an off-screen line cannot be grabbed. */ +function resetRulers() { + // Re-place every plot's rulers at the default ±2 divisions, like + // resetCursors re-places the vertical cursors. + plots.forEach(p => { + const rs = getRulerState(p.id); + rs.yA = -2; rs.yB = 2; + }); + updateCursorReadout(); + cursorsDirty = true; +} + function resetCursors() { + resetRulers(); const refPlot = plots.find(p => p.uplot); if (!refPlot) return; const { min, max } = refPlot.uplot.scales.x; @@ -2790,9 +2822,13 @@ document.getElementById('btn-ruler').addEventListener('click', () => { rulers.mode = rulers.mode === 'off' ? 'on' : 'off'; const btn = document.getElementById('btn-ruler'); btn.classList.toggle('active', rulers.mode === 'on'); - if (rulers.mode === 'on' && rulers.yA === null && rulers.yB === null) { - // Auto-place at ±2 divisions from the centre on first use. - rulers.yA = -2; rulers.yB = 2; + if (rulers.mode === 'on') { + // Auto-place every plot at ±2 divisions from the centre on first use; + // afterwards each plot keeps its own positions. + plots.forEach(pl => { + const rs = getRulerState(pl.id); + if (rs.yA === null && rs.yB === null) { rs.yA = -2; rs.yB = 2; } + }); } updateCursorReadout(); cursorsDirty = true; @@ -2809,16 +2845,9 @@ function getValueAtCursor(p, t) { if (!p.uplot || t === null) return null; const key = plotActiveSignal[p.id] || (p.traces.length === 1 ? p.traces[0] : null); if (!key) return null; - const idx = p.traces.indexOf(key); - if (idx < 0) return null; - const vNorm = interpAtTime(p.uplot, idx + 1, t); - if (vNorm === null) return null; - // Un-normalize: y_norm = (y_raw - offset) / divValue - const vs = sigVScale[p.id + ':' + key]; - if (!vs) return vNorm; - const dv = vs._resolvedDiv != null ? vs._resolvedDiv : (vs.divValue || 1); - const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); - return vNorm * dv + ofs; + // Interpolate the raw wire value and apply the calibration explicitly, so + // cursor readouts match the hover in every display mode. + return calibratedValueAt(key, t); } // Update per-plot cursor value readouts (A, B, ΔV) for all plots. @@ -2851,6 +2880,64 @@ function rawFromNorm(p, key, vNorm) { const ofs = vs._resolvedOffset != null ? vs._resolvedOffset : (vs.offset || 0); return vNorm * dv + ofs; } +// Linear interpolation of a sorted (t, v) pair at absolute time tAbs. Returns +// null outside the data's range — never fabricated, so an export or readout +// cannot invent samples the signal never had. +function interpSortedRaw(t, v, tAbs) { + if (!t || t.length === 0) return null; + if (tAbs < t[0] || tAbs > t[t.length - 1]) return null; + let lo = 0, hi = t.length - 1; + while (lo < hi) { const m = (lo + hi) >> 1; if (t[m] < tAbs) lo = m + 1; else hi = m; } + if (lo === 0) return v[0] ?? null; + const t0 = t[lo - 1], t1 = t[lo]; + const v0 = v[lo - 1], v1 = v[lo]; + if (v0 == null || v1 == null) return v0 ?? v1 ?? null; + return v0 + (tAbs - t0) / (t1 - t0) * (v1 - v0); +} + +// Binary-search linear interpolation of a circular buffer at time t. +function interpCircular(buf, t) { + if (!buf || buf.size === 0) return null; + const { cap, size, head } = buf; + const start = (size === cap) ? head : 0; + const physAt = k => (start + k) % cap; + let lo = 0, hi = size; + while (lo < hi) { const m = (lo + hi) >> 1; if (buf.t[physAt(m)] < t) lo = m + 1; else hi = m; } + if (lo === 0) return buf.v[physAt(0)] ?? null; + if (lo >= size) return buf.v[physAt(size - 1)] ?? null; + const t0 = buf.t[physAt(lo - 1)], t1 = buf.t[physAt(lo)]; + const v0 = buf.v[physAt(lo - 1)], v1 = buf.v[physAt(lo)]; + if (v0 == null || v1 == null) return v0 ?? v1 ?? null; + return v0 + (t - t0) / (t1 - t0) * (v1 - v0); +} + +// Raw (uncalibrated) value of `key` at absolute time tAbs, from the best +// available raw source: trigger snapshot → fetched zoom data → live push +// buffer. All three store wire values, so calibration is applied here, at the +// point of display, exactly once. +function rawAtAbsTime(key, tAbs) { + if (trig.snapshot) { + const s = trig.snapshot[key]; + if (s && s.t.length) { const v = interpSortedRaw(s.t, s.v, tAbs); if (v != null) return v; } + } + for (const p of plots) { + const zd = zoomData[p.id]; + if (!zd) continue; + const s = zd.signals[key]; + if (s && s.t.length) { const v = interpSortedRaw(s.t, s.v, tAbs); if (v != null) return v; } + } + const buf = buffers[key]; + if (buf && buf.size) { const v = interpCircular(buf, tAbs); if (v != null) return v; } + return null; +} + +// Calibrated value of `key` at axis time t. Under a trigger the axis is +// relative to the trigger instant, so convert to absolute first. +function calibratedValueAt(key, t) { + const tAbs = (inTrigWindow() && trig.trigTime != null) ? trig.trigTime + t : t; + const raw = rawAtAbsTime(key, tAbs); + return raw === null ? null : Calib.applyCal(raw, calForKey(key)); +} function hideHoverReadout() { document.getElementById('hover-readout').style.display = 'none'; @@ -2870,13 +2957,14 @@ function showHoverReadout(p, e) { const tStr = inTrigWindow() ? fmtDuration(t, span, true) : fmtLiveTime(t, span); let html = '
' + escHtml(tStr) + '
'; p.traces.forEach((key, idx) => { - const vNorm = interpAtTime(p.uplot, idx + 1, t); const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key; - // rawFromNorm inverts the vscale transform, which Task 7 made operate on - // calibrated values — so this is already in calibrated units. const unit = unitForKey(key); - const val = vNorm === null ? '—' - : (_fmtVal(rawFromNorm(p, key, vNorm)) + (unit ? ' ' + unit : '')); + // Interpolate the raw wire value and apply the calibration explicitly, + // so the hover is correct in every display mode (analog, digital, + // mixed) and independent of the vscale state. + const vCal = calibratedValueAt(key, t); + const val = vCal === null ? '—' + : (_fmtVal(vCal) + (unit ? ' ' + unit : '')); html += '
' + '' + escHtml(name) + '' + @@ -2894,17 +2982,25 @@ function showHoverReadout(p, e) { el.style.top = Math.max(4, y) + 'px'; } -// Update the Y1/Y2/ΔY ruler readout, expressed in the raw units of the first -// plot that has an active (or sole) signal. +// Update the Y1/Y2/ΔY ruler readout, expressed in the raw units of the plot +// whose rulers were last moved, falling back to the first plot with a signal. function updateRulerReadout() { const box = document.getElementById('ruler-readout'); const on = rulers.mode === 'on'; box.style.display = on ? '' : 'none'; if (!on) return; - const ref = plots.find(p => p.uplot && p.traces.length > 0 && - rulerRawValue(p, 0) !== null); - const conv = y => (y === null || !ref) ? null : rulerRawValue(ref, y); - const vA = conv(rulers.yA), vB = conv(rulers.yB); + let ref = null; + if (rulers.plotId !== null) { + const pl = plots.find(p => p.id === rulers.plotId); + if (pl && pl.uplot && pl.traces.length > 0) ref = pl; + } + if (!ref) { + ref = plots.find(p => p.uplot && p.traces.length > 0 && + rulerRawValue(p, 0) !== null) || null; + } + const rs = ref ? rulerState[ref.id] : null; + const conv = y => (y === null || !ref || !rs) ? null : rulerRawValue(ref, y); + const vA = conv(rs ? rs.yA : null), vB = conv(rs ? rs.yB : null); document.getElementById('cur-y1').textContent = 'Y1: ' + fmtVal(vA); document.getElementById('cur-y2').textContent = 'Y2: ' + fmtVal(vB); document.getElementById('cur-dy').textContent = @@ -3355,23 +3451,42 @@ function initPlotCfgBar(plotId, p) { /* ════════════════════════════════════════════════════════════════ Layout management ════════════════════════════════════════════════════════════════ */ -// Returns the number of plot cells in a layout (cols × rows). +// Returns the number of plot cells in a layout. Custom layouts carry an +// explicit plotCount; uniform ones are cols × rows. function layoutPlotCount(cls) { + const entry = LAYOUTS.find(l => l[1] === cls); + if (entry) { + if (entry.length >= 5) return entry[4]; + return entry[2] * entry[3]; + } const m = cls.match(/^l(\d+)x(\d+)$/); return m ? parseInt(m[1]) * parseInt(m[2]) : 1; } -// Build a small SVG grid thumbnail for a given cols×rows layout. -function layoutSVG(cols, rows) { +// Build a small SVG grid thumbnail for a layout entry. Custom (non-uniform) +// layouts draw their own cell arrangement. +function layoutSVG(entry) { const W = 28, H = 20, GAP = 1.5, PAD = 1.5; - const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols; - const ch = (H - PAD * 2 - GAP * (rows - 1)) / rows; let rects = ''; - for (let r = 0; r < rows; r++) { - for (let c = 0; c < cols; c++) { - const x = (PAD + c * (cw + GAP)).toFixed(1); - const y = (PAD + r * (ch + GAP)).toFixed(1); - rects += ``; + if (entry[1] === 'l1p2') { + // 1+2: one full-width cell on top, two side by side below. + const cw = (W - PAD * 2 - GAP) / 2; + const ch = (H - PAD * 2 - GAP) / 2; + const y2 = (PAD + ch + GAP).toFixed(1); + const x2 = (PAD + cw + GAP).toFixed(1); + rects += ``; + rects += ``; + rects += ``; + } else { + const [, , cols, rows] = entry; + const cw = (W - PAD * 2 - GAP * (cols - 1)) / cols; + const ch = (H - PAD * 2 - GAP * (rows - 1)) / rows; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const x = (PAD + c * (cw + GAP)).toFixed(1); + const y = (PAD + r * (ch + GAP)).toFixed(1); + rects += ``; + } } } return `` @@ -3401,7 +3516,7 @@ function applyLayout(cls) { // Update button label const btn = document.getElementById('btn-layout'); - if (btn) btn.innerHTML = layoutSVG(cols, rows) + ' ' + label + ' ▾'; + if (btn) btn.innerHTML = layoutSVG(entry) + ' ' + label + ' ▾'; // Update active state in menu document.querySelectorAll('.layout-menu-item') @@ -3436,11 +3551,12 @@ function applyLayout(cls) { function buildLayoutMenu() { const menu = document.getElementById('layout-menu'); - LAYOUTS.forEach(([label, cls, cols, rows]) => { + LAYOUTS.forEach(entry => { + const [label, cls] = entry; const item = document.createElement('button'); item.className = 'layout-menu-item' + (cls === currentLayout ? ' active' : ''); item.dataset.layout = cls; - item.innerHTML = layoutSVG(cols, rows) + '' + label + ''; + item.innerHTML = layoutSVG(entry) + '' + label + ''; item.addEventListener('click', () => { applyLayout(cls); menu.classList.remove('open'); @@ -3467,9 +3583,20 @@ function buildLayoutMenu() { /* ════════════════════════════════════════════════════════════════ Export CSV (all plots) — fetches full-resolution data from ring ════════════════════════════════════════════════════════════════ */ +// Shared busy state for the export dropdown: prevents re-entry and shows +// progress on the selector while a (possibly large) export runs. +let exportBusy = false; +function setExportBusy(busy) { + exportBusy = busy; + const sel = document.getElementById('export-select'); + if (!sel) return; + sel.disabled = busy; + const ph = sel.querySelector('option[value=""]'); + if (ph) ph.textContent = busy ? '\u23f3 Exporting\u2026' : '\u23ea Export'; +} + async function exportAllCSV() { - const btn = document.getElementById('btn-csv-all'); - if (btn.disabled) return; + if (exportBusy) return; const inTrigMode = trig.enabled && trig.snapshot !== null; @@ -3482,8 +3609,8 @@ async function exportAllCSV() { let t0, t1, relOffset = 0; if (inTrigMode) { // Export the full trigger window around the trigger event. - t0 = trig.trigTime - trigPreSec(); - t1 = trig.trigTime + trigPostSec(); + t0 = trig.trigTime - activePreSec(); + t1 = trig.trigTime + activePostSec(); relOffset = trig.trigTime; } else { // Use the current zoom range if active, else the rolling window. @@ -3504,60 +3631,52 @@ async function exportAllCSV() { t1 = plotNow; } } + if (!(t1 > t0)) return; - // Show loading state. - const origLabel = btn.textContent; - btn.textContent = '⏳ Downloading…'; - btn.disabled = true; + exportBusy = true; + // Cap the export. A full window at a megasample rate is hundreds of MB raw + // (the old exact-timestamp merge exploded into millions of rows and crashed + // the tab); ask the hub for a min/max-decimated envelope — the same scope + // style reduction the live view uses — and cap the number of rows. + const BUDGET = 100000; // max rows per signal + setExportBusy(true); - // Fetch full-resolution ring data (n=0 → no decimation). let ringSignals = null; - try { - ringSignals = await wsZoomRequest(t0, t1, 0, keys); - } catch (e) { - console.warn('CSV export: ring fetch failed, falling back to push buffer', e); - } finally { - btn.textContent = origLabel; - btn.disabled = false; + if (!inTrigMode) { + try { + ringSignals = await wsZoomRequest(t0, t1, BUDGET, keys); + } catch (e) { + console.warn('CSV export: ring fetch failed, falling back to local data', e); + } } + setExportBusy(false); - // Build per-signal time/value arrays. - // Priority: ring buffer (full res) → trigger snapshot → push buffer. + // Per-signal raw source: hub ring (whole window, decimated) → trigger + // snapshot (already \u226420k pts) → local push buffer. const slices = keys.map(key => { - const rd = ringSignals && ringSignals[key]; - if (rd && rd.t && rd.t.length > 0) { - const t = rd.t, v = rd.v; - if (inTrigMode) { - return { t: Array.from(t).map(ts => ts - relOffset), v: Array.from(v) }; - } - return { t: Array.from(t), v: Array.from(v) }; + if (!inTrigMode) { + const rd = ringSignals && ringSignals[key]; + if (rd && rd.t && rd.t.length > 0) return { key, t: rd.t, v: rd.v }; } - // Fallback: push buffer or trigger snapshot. if (inTrigMode) { const raw = trig.snapshot[key] || { t: new Float64Array(0), v: new Float64Array(0) }; - return { t: Array.from(raw.t).map(ts => ts - relOffset), v: Array.from(raw.v) }; + return { key, t: raw.t, v: raw.v }; } - const buf = buffers[key]; if (!buf) return { t: [], v: [] }; - const sl = getBufferSliceRange(buf, t0, t1); - return { t: Array.from(sl.t), v: Array.from(sl.v) }; + const buf = buffers[key]; + const sl = buf ? getBufferSliceRange(buf, t0, t1) : { t: new Float64Array(0), v: new Float64Array(0) }; + return { key, t: sl.t, v: sl.v }; }); + const present = slices.filter(s => s.t.length > 0); + if (!present.length) return; - // Merge all timestamps and build aligned rows. - const allT = new Set(); - slices.forEach(s => s.t.forEach(t => allT.add(t))); - const sortedT = Array.from(allT).sort((a, b) => a - b); - if (!sortedT.length) return; + // Master time grid = the signal with the most samples; every other signal is + // resampled onto it (linear, no extrapolation). Cells outside a signal's own + // span stay empty rather than being fabricated, so continuous signals export + // without holes and no value is invented. + let master = present[0]; + present.forEach(s => { if (s.t.length > master.t.length) master = s; }); - const lookups = slices.map(s => { - const m = new Map(); - s.t.forEach((t, i) => m.set(t, s.v[i])); - return m; - }); - - // Strip "sourceId:" prefix from column headers for readability, and append - // the effective unit. These values come straight from the ring/history/ - // snapshot and never pass through applyVScaleNorm, so calibrate them here. - const cals = keys.map(k => calForKey(k)); + const cals = new Map(keys.map(k => [k, calForKey(k)])); const displayKeys = keys.map(k => { const name = k.includes(':') ? k.split(':').slice(1).join(':') : k; const u = unitForKey(k); @@ -3566,10 +3685,21 @@ async function exportAllCSV() { }); const timeCol = '"' + (inTrigMode ? 'time_rel_s' : 'time_s') + '"'; const hdr = [timeCol, ...displayKeys].join(','); - const rows = sortedT.map(t => - [t.toFixed(9), ...lookups.map((lk, i) => - lk.has(t) ? Calib.applyCal(lk.get(t), cals[i]) : '')].join(',') - ); + + const rows = new Array(master.t.length); + for (let i = 0; i < master.t.length; i++) { + const tAbs = master.t[i]; + const cells = present.map(s => { + if (s === master) { + return Calib.applyCal(master.v[i], cals.get(s.key)); + } + const v = interpSortedRaw(s.t, s.v, tAbs); + return v === null ? '' : Calib.applyCal(v, cals.get(s.key)); + }); + const tt = inTrigMode ? tAbs - relOffset : tAbs; + rows[i] = [tt.toFixed(9), ...cells].join(','); + } + const blob = new Blob([hdr + '\n' + rows.join('\n')], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); @@ -3958,6 +4088,7 @@ function onSources(msg) { }); buildSidebar(); if (statsOpen) _refreshStatsSelector(); + maybeRestoreViewLate(); } function addSourceWS(label, addr, multicastGroup, dataPort) { @@ -4582,7 +4713,303 @@ initSignalMenu(); const cb = document.getElementById('cb-monotonic'); if (cb) cb.checked = localStorage.getItem('udpscope.monotonic') === '1'; } -document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV); +// Export every stored sample of the plotted signals as a Parquet file, served +// by the Go hub's /api/export. Full resolution (no decimation) and hole-free +// (each signal keeps its own timestamps — long format). The file can be huge +// (hundreds of MB at high rates), so stream it to disk when the File System +// Access API is available instead of holding it in a Blob. +async function exportParquet() { + if (exportBusy) return; + + const inTrigMode = trig.enabled && trig.snapshot !== null; + const keys = []; + plots.forEach(p => p.traces.forEach(k => { if (!keys.includes(k)) keys.push(k); })); + if (!keys.length) return; + + // Same range resolution as the CSV export. + let t0, t1; + if (inTrigMode) { + t0 = trig.trigTime - activePreSec(); + t1 = trig.trigTime + activePostSec(); + } else { + const refPlot = plots.find(p => p.xRange); + if (refPlot) { + [t0, t1] = refPlot.xRange; + } else { + let plotNow = -Infinity; + keys.forEach(k => { + const buf = buffers[k]; + if (buf && buf.size > 0) { + const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap]; + if (t > plotNow) plotNow = t; + } + }); + if (!isFinite(plotNow)) plotNow = Date.now() / 1000; + t0 = plotNow - windowSec; + t1 = plotNow; + } + } + if (!(t1 > t0)) return; + + exportBusy = true; + setExportBusy(true); + try { + const url = '/api/export?t0=' + t0.toFixed(9) + '&t1=' + t1.toFixed(9) + + '&signals=' + encodeURIComponent(keys.join(',')); + const resp = await fetch(url); + if (!resp.ok) { + alert('Parquet export failed (HTTP ' + resp.status + ').\n\n' + + 'The /api/export endpoint is provided by the Go hub; the C++ ' + + 'StreamHub does not serve it.'); + return; + } + const filename = 'signals_' + Date.now() + '.parquet'; + if (window.showSaveFilePicker && resp.body) { + try { + const handle = await window.showSaveFilePicker({ + suggestedName: filename, + types: [{ description: 'Parquet', accept: { 'application/vnd.apache.parquet': ['.parquet'] } }], + }); + const writable = await handle.createWritable(); + await resp.body.pipeTo(writable); + return; + } catch (e) { + if (e && e.name === 'AbortError') return; // user cancelled the picker + console.warn('parquet export: file picker failed, falling back to Blob', e); + } + } + const blob = await resp.blob(); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); + } catch (e) { + console.warn('parquet export failed', e); + alert('Parquet export failed: ' + e.message); + } finally { + setExportBusy(false); + } +} + +// Export dropdown: dispatch on selection, then reset to the placeholder so the +// same format can be chosen again. +document.getElementById('export-select').addEventListener('change', () => { + const sel = document.getElementById('export-select'); + const fmt = sel.value; + sel.value = ''; + if (fmt === 'csv') exportAllCSV(); + else if (fmt === 'parquet') exportParquet(); +}); + +/* ════════════════════════════════════════════════════════════════ + View-state persistence (cookie) + ════════════════════════════════════════════════════════════════ */ +// The whole client view — layout, plots (traces/titles/modes), window, trigger +// configuration, rulers, sources — is serialised into one cookie so a reload +// restores the previous view. Cookies are size-limited, so the state degrades +// gracefully (rulers → trigger → sources → traces) when it would not fit. +const VIEW_COOKIE = 'udpscope.view'; +const VIEW_COOKIE_MAX = 3500; // encoded chars; browsers cap cookies at ~4 KiB + +function packViewState() { + const state = { + v: 1, + windowSec: windowSec, + layout: currentLayout, + plots: plots.map(p => ({ + title: p.title, + mode: p.mode, + traces: p.traces.map(k => { + const colon = k.indexOf(':'); + const name = colon >= 0 ? k.slice(colon + 1) : k; + return { key: k, label: srcLabelForKey(k), name }; + }), + })), + trig: { + enabled: trig.enabled, signal: trig.signal, edge: trig.edge, + threshold: trig.threshold, windowSec: trig.windowSec, + prePercent: trig.prePercent, mode: trig.mode, holdoffSec: trig.holdoffSec, + }, + rulers: { + mode: rulers.mode, + plotId: plots.findIndex(p => p.id === rulers.plotId), + states: plots.map(p => { + const rs = rulerState[p.id]; + return rs ? { yA: rs.yA, yB: rs.yB } : { yA: null, yB: null }; + }), + }, + sources: Object.values(sourcesMap).map(s => ({ + label: s.label || s.addr || s.id, addr: s.addr, + })), + }; + let s = JSON.stringify(state); + const tooBig = () => encodeURIComponent(s).length > VIEW_COOKIE_MAX; + if (tooBig()) { delete state.rulers; s = JSON.stringify(state); } + if (tooBig()) { delete state.trig; s = JSON.stringify(state); } + if (tooBig()) { delete state.sources; s = JSON.stringify(state); } + if (tooBig()) { + state.plots = state.plots.map(p => ({ title: p.title, mode: p.mode })); + s = JSON.stringify(state); + } + if (tooBig()) { state.plots = []; s = JSON.stringify(state); } + return s; +} + +// Saves are gated until the saved view has been re-applied (phase 2) or the +// grace timeout fires: otherwise the very first periodic save would overwrite +// the cookie with the not-yet-restored (empty) state and destroy it. +let _viewSaveReady = false; +function saveViewState() { + if (!_viewSaveReady) return; + try { + const s = packViewState(); + document.cookie = VIEW_COOKIE + '=' + encodeURIComponent(s) + + '; path=/; max-age=31536000; SameSite=Lax'; + } catch (e) { + console.warn('view cookie save failed', e); + } +} + +function readViewState() { + try { + const prefix = VIEW_COOKIE + '='; + const m = document.cookie.split('; ').find(c => c.startsWith(prefix)); + if (!m) return null; + const st = JSON.parse(decodeURIComponent(m.slice(prefix.length))); + return (st && st.v === 1) ? st : null; + } catch (e) { + return null; + } +} + +// Phase 1 (init): layout, plot cards (titles/modes), window, rulers. Traces and +// the trigger need sources/signals loaded, so they are applied in phase 2. +function restoreViewState() { + const st = readViewState(); + if (!st) return; + + if (st.layout && LAYOUTS.some(l => l[1] === st.layout)) applyLayout(st.layout); + + const plotState = st.plots || []; + plotState.forEach((ps, i) => { + const p = plots[i]; + if (!p) return; + if (ps.title && ps.title !== 'Plot ' + p.id) { + p.title = ps.title; + const tEl = document.getElementById('ptitle-' + p.id); + if (tEl) tEl.textContent = ps.title; + const inp = document.querySelector('#pcfg-' + p.id + ' .pcfg-title-input'); + if (inp) inp.value = ps.title; + } + if (ps.mode && ps.mode !== p.mode) { + p.mode = ps.mode; + document.querySelectorAll('#pcfg-' + p.id + ' .pcfg-mode-btn') + .forEach(b => b.classList.toggle('active', b.dataset.mode === ps.mode)); + } + }); + + if (st.windowSec != null) { + windowSec = st.windowSec; + const sel = document.getElementById('window-select'); + if (sel && [...sel.options].some(o => o.value === String(st.windowSec))) { + sel.value = String(st.windowSec); + } + } + + if (st.rulers) { + rulers.mode = st.rulers.mode === 'on' ? 'on' : 'off'; + const btn = document.getElementById('btn-ruler'); + if (btn) btn.classList.toggle('active', rulers.mode === 'on'); + (st.rulers.states || []).forEach((rs, i) => { + const p = plots[i]; + if (!p || !rs) return; + const cur = getRulerState(p.id); + cur.yA = rs.yA; cur.yB = rs.yB; + }); + if (st.rulers.plotId != null && plots[st.rulers.plotId]) { + rulers.plotId = plots[st.rulers.plotId].id; + } + } +} + +// Rebuild a saved trace key against the current sources: source ids change +// across restarts, so match by label and fall back to the saved id-key. +function restoreTraceKey(entry) { + const src = Object.values(sourcesMap).find(s => (s.label || s.id) === entry.label); + return src ? (src.id + ':' + entry.name) : entry.key; +} + +// Phase 2 (first sources + signals): reconcile sources, re-apply traces, then +// the trigger configuration and the window to the hub. +let _viewLateRestored = false; +function maybeRestoreViewLate() { + if (_viewLateRestored) return; + const st = readViewState(); + if (!st) { _viewLateRestored = true; return; } + + // Add any saved sources the hub does not already have (it persists its own). + const known = Object.values(sourcesMap).map(s => (s.label || s.addr || s.id) + '\u0000' + s.addr); + let added = false; + (st.sources || []).forEach(sv => { + const k = (sv.label || sv.addr) + '\u0000' + (sv.addr || ''); + if (!known.includes(k)) { addSourceWS(sv.label, sv.addr, sv.multicastGroup, sv.dataPort); added = true; } + }); + if (added) return; // re-enter when the new sources appear + + // Traces and the trigger selector need at least one source with signals. + if (!Object.values(sourcesMap).some(s => (s.signals || []).length > 0)) return; + _viewLateRestored = true; + _viewSaveReady = true; + + (st.plots || []).forEach((ps, i) => { + const p = plots[i]; + if (!p) return; + (ps.traces || []).forEach(t => addTraceTo(p.id, restoreTraceKey(t))); + }); + + if (st.windowSec != null) { + windowSec = st.windowSec; + sendWindow(); + } + + const t = st.trig; + if (t) { + trig.edge = t.edge || trig.edge; + if (t.threshold != null) trig.threshold = t.threshold; + if (t.windowSec != null) trig.windowSec = t.windowSec; + if (t.prePercent != null) trig.prePercent = t.prePercent; + trig.mode = t.mode || trig.mode; + if (t.holdoffSec != null) trig.holdoffSec = t.holdoffSec; + trig.signal = t.signal || ''; + const el = id => document.getElementById(id); + if (el('trig-edge')) el('trig-edge').value = trig.edge; + if (el('trig-window')) el('trig-window').value = String(trig.windowSec); + if (el('trig-mode')) el('trig-mode').value = trig.mode; + if (el('trig-holdoff')) el('trig-holdoff').value = trig.holdoffSec; + if (el('trig-pre')) el('trig-pre').value = String(trig.prePercent); + if (el('trig-pre-val')) el('trig-pre-val').textContent = trig.prePercent + '%'; + refreshTrigThresholdField(); + const selSig = document.getElementById('trig-signal'); + if (selSig && trig.signal) { + const base = trig.signal.replace(/\[\d+\]$/, ''); + if ([...selSig.options].some(o => o.value === base)) selSig.value = base; + } + if (t.enabled) openTrigBar(true); + else updateTrigStatusBadge('idle'); + } +} + +// Periodic save keeps the cookie current without wiring every control; the +// pagehide save captures the final state on close/reload. +setInterval(saveViewState, 3000); +window.addEventListener('pagehide', saveViewState); +// Hub down / never connected: stop gating after 10 s so layout, window and +// rulers still persist even though traces and the trigger could not be +// restored. +setTimeout(() => { _viewSaveReady = true; }, 10000); + + document.getElementById('history-badge').addEventListener('click', toggleHistoryPanel); document.getElementById('btn-hist-cancel').addEventListener('click', toggleHistoryPanel); document.getElementById('btn-hist-apply').addEventListener('click', applyHistoryBudget); @@ -4592,6 +5019,7 @@ document.getElementById('stats-source-sel').addEventListener('change', e => { statsSelectedSrc = e.target.value || null; renderStats(); }); +restoreViewState(); resolveHub().then(connectWS); requestAnimationFrame(renderDirtyPlots); fetch('/version').then(r => r.text()).then(v => { diff --git a/Client/udpstreamer/static/index.html b/Client/udpstreamer/static/index.html index bdce18e..6d0c300 100644 --- a/Client/udpstreamer/static/index.html +++ b/Client/udpstreamer/static/index.html @@ -41,7 +41,11 @@ - + diff --git a/Client/udpstreamer/static/style.css b/Client/udpstreamer/static/style.css index b84a045..427d558 100644 --- a/Client/udpstreamer/static/style.css +++ b/Client/udpstreamer/static/style.css @@ -14,6 +14,11 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body { height:100%; background:var(--bg); color:var(--text); font-family:'Segoe UI',system-ui,sans-serif; font-size:14px; overflow:hidden; } +/* Uniform 0.9x compaction — scales every element (fonts, bars, plots, + spacing) while reflowing layout. `zoom` (Chrome/Edge/Safari, Firefox 126+) + is preferred over `transform: scale` because it reflows, so fixed-position + bars and JS-computed offsets stay aligned. */ +html { zoom: 0.9; } ::-webkit-scrollbar { width:6px; } ::-webkit-scrollbar-track { background:var(--mantle); } ::-webkit-scrollbar-thumb { background:var(--surface1); border-radius:3px; } @@ -256,6 +261,9 @@ input[type=range].trig-range::-webkit-slider-thumb { #plot-grid.l2x3 { grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr 1fr; } #plot-grid.l1x4 { grid-template-columns:1fr; grid-template-rows:1fr 1fr 1fr 1fr; } #plot-grid.l4x1 { grid-template-columns:1fr 1fr 1fr 1fr; grid-template-rows:1fr; } +/* 1+2 layout: one plot spanning the top row, two side by side below. */ +#plot-grid.l1p2 { grid-template-columns:1fr 1fr; grid-template-rows:1fr 1fr; } +#plot-grid.l1p2 .plot-card:first-child { grid-column: 1 / -1; } /* ── Plot card ────────────────────────────────────────────────── */ .plot-card { diff --git a/Common/Client/go/go.mod b/Common/Client/go/go.mod index 25ddedf..7484841 100644 --- a/Common/Client/go/go.mod +++ b/Common/Client/go/go.mod @@ -1,7 +1,19 @@ module marte2/common -go 1.21 +go 1.24.9 require github.com/gorilla/websocket v1.5.1 -require golang.org/x/net v0.17.0 // indirect +require ( + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/parquet-go/bitpack v1.0.0 // indirect + github.com/parquet-go/jsonlite v1.0.0 // indirect + github.com/parquet-go/parquet-go v0.32.0 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/twpayne/go-geom v1.6.1 // indirect + golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.38.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/Common/Client/go/go.sum b/Common/Client/go/go.sum index 272772f..8c30f05 100644 --- a/Common/Client/go/go.sum +++ b/Common/Client/go/go.sum @@ -1,4 +1,25 @@ +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/parquet-go/bitpack v1.0.0 h1:AUqzlKzPPXf2bCdjfj4sTeacrUwsT7NlcYDMUQxPcQA= +github.com/parquet-go/bitpack v1.0.0/go.mod h1:XnVk9TH+O40eOOmvpAVZ7K2ocQFrQwysLMnc6M/8lgs= +github.com/parquet-go/jsonlite v1.0.0 h1:87QNdi56wOfsE5bdgas0vRzHPxfJgzrXGml1zZdd7VU= +github.com/parquet-go/jsonlite v1.0.0/go.mod h1:nDjpkpL4EOtqs6NQugUsi0Rleq9sW/OtC1NnZEnxzF0= +github.com/parquet-go/parquet-go v0.32.0 h1:NWDqTUHfrCS4cJP/Fj2HlxvqsrVedWG3sayMkf+znzM= +github.com/parquet-go/parquet-go v0.32.0/go.mod h1:navtkAYr2LGoJVp141oXPlO/sxLvaOe3la2JEoD8+rg= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/twpayne/go-geom v1.6.1 h1:iLE+Opv0Ihm/ABIcvQFGIiFBXd76oBIar9drAwHFhR4= +github.com/twpayne/go-geom v1.6.1/go.mod h1:Kr+Nly6BswFsKM5sd31YaoWS5PeDDH2NftJTK7Gd028= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/Common/Client/go/wshub/history.go b/Common/Client/go/wshub/history.go index fe2f888..1f03b3c 100644 --- a/Common/Client/go/wshub/history.go +++ b/Common/Client/go/wshub/history.go @@ -422,6 +422,26 @@ func (hw *historyWriter) window() float64 { return hw.windowSec } +// coversWindow reports whether the archive file for key currently spans at +// least sec seconds. When true, backfillCaptureHead can reconstruct a capture's +// front out of the archive, so the trigger need not wait for the ring to cover +// the whole window on its own. +func (hw *historyWriter) coversWindow(key string, sec float64) bool { + if !hw.enabled() || !(sec > 0) { + return false + } + hw.mu.RLock() + hf, ok := hw.files[key] + hw.mu.RUnlock() + if !ok { + return false + } + hf.mu.RLock() + span := hf.tNewest - hf.tOldest + hf.mu.RUnlock() + return span >= sec +} + // setWindow points the archive at the timespan the clients are looking at, and // re-sizes the files that no longer match it. It reports whether any file's // geometry changed, which invalidates what clients know about the archive. diff --git a/Common/Client/go/wshub/trigger.go b/Common/Client/go/wshub/trigger.go index b1cb506..6ff6d8a 100644 --- a/Common/Client/go/wshub/trigger.go +++ b/Common/Client/go/wshub/trigger.go @@ -92,6 +92,14 @@ type triggerEngine struct { bufGrowth float64 bufKnown bool bufRateOK bool + // bufCoverage is the maximum span (seconds) the ring can reach at its + // current bucket and capacity — the gate must never demand more than this, + // or a ring whose coverage is below the window can never satisfy it. 0 = + // unknown (no measurable rate). + bufCoverage float64 + // bufArchived is true when the disk history already spans the trigger + // window, so a short capture's front can be back-filled from it. + bufArchived bool // Reference point the growth is measured against. bufRefSpan, bufRefWall float64 @@ -179,6 +187,7 @@ func (te *triggerEngine) SetConfig(cfg trigConfig) { if base != te.baseKey { // The buffer measurement belongs to the old signal's ring. te.bufKnown, te.bufRateOK = false, false + te.bufCoverage, te.bufArchived = 0, false } te.baseKey, te.elemIdx = base, idx te.prevValid = false @@ -285,13 +294,16 @@ const bufGrowthIntervalSec = 0.5 const bufGrowthSmooth = 0.5 // setBuffered records how far back the trigger signal's ring reaches, at wall -// clock now, and derives how fast that is growing. Pass known=false when there -// is no such ring. -func (te *triggerEngine) setBuffered(span float64, known bool, now float64) { +// clock now, and derives how fast that is growing. coverage is the maximum +// span (seconds) the ring can reach at its current bucket/capacity; archived +// says the disk history already spans the trigger window. Pass known=false when +// there is no ring to measure. +func (te *triggerEngine) setBuffered(span, coverage float64, archived, known bool, now float64) { te.mu.Lock() defer te.mu.Unlock() if !known { te.bufKnown, te.bufRateOK = false, false + te.bufCoverage, te.bufArchived = 0, false return } if !te.bufKnown { @@ -299,6 +311,8 @@ func (te *triggerEngine) setBuffered(span float64, known bool, now float64) { te.bufRefSpan, te.bufRefWall = span, now } te.bufSpan = span + te.bufCoverage = coverage + te.bufArchived = archived dt := now - te.bufRefWall if dt < bufGrowthIntervalSec { return @@ -336,9 +350,17 @@ func (te *triggerEngine) setBuffered(span float64, known bool, now float64) { // anyway. A full one grows only as fast as its incoming samples free space — // re-bucketing to a longer window replaces dense old samples with sparse new // ones — and it is that case, growth well below 1, where firing on the -// pre-window alone delivers a capture whose front has been overwritten by the -// time it is read. In the steady state growth is 0 and need is the whole -// window, which a ring tuned for that window already exceeds, so nothing waits. +// +// Two escapes keep an armed trigger from staying deaf forever: +// +// - archived — the disk history already spans the window, so the front of a +// capture can be back-filled from it; the ring only needs to +// hold the pre-window worth of recent data. +// - coverage — never demand more than the ring can physically reach. If its +// coverage saturates below the window (a measured source rate +// that over-estimates the true one), the gate opens once the +// ring is full anyway and a short capture is delivered instead +// of deafness. func (te *triggerEngine) fillNeedLocked() float64 { pre := te.cfg.windowSec * te.cfg.prePercent / 100 growth := 0.0 // until measured, assume the buffer will not fill on its own @@ -349,6 +371,14 @@ func (te *triggerEngine) fillNeedLocked() float64 { if need < pre { need = pre } + if te.bufArchived { + // The archive back-fills the front; the ring holds the post-trigger + // window live, so the pre-window is all it needs to have reached. + return pre + } + if te.bufCoverage > 0 && need > te.bufCoverage { + need = te.bufCoverage + } return need } @@ -655,19 +685,32 @@ func (h *Hub) refreshTriggerFill() { return } now := float64(time.Now().UnixNano()) / 1e9 + key := h.trigger.baseSignalKey() var rb *sigRing - if key := h.trigger.baseSignalKey(); key != "" { + if key != "" { rb = h.getRing(key) } if rb == nil { // Nothing to measure. Do not gate on a signal the hub does not carry: // that would leave the trigger armed forever, which is worse than a // short capture. - h.trigger.setBuffered(0, false, now) + h.trigger.setBuffered(0, 0, false, false, now) return } _, span := rb.stats() - h.trigger.setBuffered(span, true, now) + // Maximum span the ring can ever reach at its current bucket/capacity, in + // seconds. The gate must never demand more than this, or a ring whose + // coverage is below the window (a measured source rate that over-estimates + // the true one) can never satisfy it. + coverage := 0.0 + if rate := rb.sourceRate(); rate > 0 { + coverage = float64(ringCoverage(rb.bucketSize(), rb.capacity())) / rate + } + // If the disk archive already spans the trigger window, the front of a + // short capture can be back-filled from it, so the ring need not cover the + // whole window on its own. + archived := h.hist.coversWindow(key, h.trigger.Config().windowSec) + h.trigger.setBuffered(span, coverage, archived, true, now) } // triggerTick services the trigger FSM; called from Hub.Run() on every push tick. diff --git a/Common/Client/go/wshub/trigger_sporadic_test.go b/Common/Client/go/wshub/trigger_sporadic_test.go index b3c5f44..a900758 100644 --- a/Common/Client/go/wshub/trigger_sporadic_test.go +++ b/Common/Client/go/wshub/trigger_sporadic_test.go @@ -123,7 +123,7 @@ func (s pulseTrainSim) run(t *testing.T, key string) pulseTrainResult { tick := now + s.batchSec h.retuneRings(tick) _, span := h.rings[key].stats() - h.trigger.setBuffered(span, true, tick) + h.trigger.setBuffered(span, 0, false, true, tick) if trigTime, pre, post, ok := h.trigger.dueCapture(tick); ok { if buf := h.buildTriggerCapture(trigTime, pre, post); buf != nil { diff --git a/Common/Client/go/wshub/trigger_test.go b/Common/Client/go/wshub/trigger_test.go index cda4758..e610845 100644 --- a/Common/Client/go/wshub/trigger_test.go +++ b/Common/Client/go/wshub/trigger_test.go @@ -297,9 +297,9 @@ func TestCollectingIsBroadcast(t *testing.T) { // later. It forgets any earlier measurement first, so the rate is the one // asked for rather than a blend with it. func setFill(te *triggerEngine, span, growth, now float64) { - te.setBuffered(0, false, now) - te.setBuffered(span-growth, true, now) - te.setBuffered(span, true, now+1) + te.setBuffered(0, 0, false, false, now) + te.setBuffered(span-growth, 0, false, true, now) + te.setBuffered(span, 0, false, true, now+1) } // What has to hold is that the buffer spans the whole window by the time the @@ -417,9 +417,9 @@ func TestForceIgnoresFillGate(t *testing.T) { // interval, so they refresh the span and leave the seeded rate alone. func seedFillNow(te *triggerEngine, span, growth float64) { now := float64(time.Now().UnixNano()) / 1e9 - te.setBuffered(0, false, now-1) - te.setBuffered(span-growth, true, now-1) - te.setBuffered(span, true, now) + te.setBuffered(0, 0, false, false, now-1) + te.setBuffered(span-growth, 0, false, true, now-1) + te.setBuffered(span, 0, false, true, now) } // While it holds off, the trigger looks identical to one that is ignoring @@ -505,3 +505,55 @@ func drainStates(t *testing.T, h *Hub) []map[string]any { } } } + +// A ring whose coverage saturates below the window (measured source rate that +// over-estimates the true one) can never satisfy the full-window need. The +// coverage clamp must open the gate once the ring is full, delivering a short +// capture rather than staying deaf forever. +func TestFillNeedClampedToCoverage(t *testing.T) { + te := newTriggerEngine() + te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 60, prePercent: 20, mode: "normal", holdoffSec: 0.2}) + setFill(te, 50, 0, 100) // ring full at 50 s, no growth + te.mu.Lock() + te.bufCoverage = 50 // the ring can never reach further back + te.mu.Unlock() + + if need := te.fillNeedLocked(); need != 50 { + t.Errorf("need = %v, want 50 (clamped to coverage, not the 60 s window)", need) + } + if f := te.fillLocked(); f < 1 { + t.Errorf("fillLocked = %v, want >= 1: a full ring below the window must still open the gate", f) + } + + // Without the clamp the gate would stay shut forever. + te.mu.Lock() + te.bufCoverage = 0 + te.mu.Unlock() + if f := te.fillLocked(); f >= 1 { + t.Errorf("baseline: fillLocked = %v, want < 1 without a coverage clamp", f) + } +} + +// When the disk archive already spans the window it can back-fill the front of +// a capture, so the gate must only require the ring to have reached the +// pre-window, not the whole window. +func TestFillNeedArchiveLowersToPreWindow(t *testing.T) { + te := newTriggerEngine() + te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 60, prePercent: 20, mode: "normal", holdoffSec: 0.2}) + setFill(te, 30, 0, 100) // ring holds only 30 s, no growth → need 60 without archive + te.mu.Lock() + te.bufArchived = true + te.mu.Unlock() + + if want := 12.0; te.fillNeedLocked() != want { // 60 * 0.20 + t.Errorf("need = %v, want %v (archive lowers to the pre-window)", te.fillNeedLocked(), want) + } + + // A ring holding just the pre-window opens the gate once archived. + te.mu.Lock() + te.bufSpan = 12 + te.mu.Unlock() + if f := te.fillLocked(); f < 1 { + t.Errorf("fillLocked = %v, want >= 1 with pre-window buffered and the archive available", f) + } +} diff --git a/Makefile.gcc b/Makefile.gcc index d142e2a..a555163 100644 --- a/Makefile.gcc +++ b/Makefile.gcc @@ -20,6 +20,8 @@ core: $(MAKE) -C Source/Components/DataSources/UDPStreamerClient -f Makefile.gcc $(MAKE) -C Source/Components/GAMs/SineArrayGAM -f Makefile.gcc $(MAKE) -C Source/Components/GAMs/TimeArrayGAM -f Makefile.gcc + $(MAKE) -C Source/Components/GAMs/PulseGeneratorGAM -f Makefile.gcc + $(MAKE) -C Source/Components/GAMs/SlowControlGAM -f Makefile.gcc $(MAKE) -C Source/Components/Interfaces/TCPLogger -f Makefile.gcc $(MAKE) -C Source/Components/Interfaces/DebugService -f Makefile.gcc @@ -37,6 +39,8 @@ clean: $(MAKE) -C Source/Components/DataSources/UDPStreamerClient -f Makefile.gcc clean $(MAKE) -C Source/Components/GAMs/SineArrayGAM -f Makefile.gcc clean $(MAKE) -C Source/Components/GAMs/TimeArrayGAM -f Makefile.gcc clean + $(MAKE) -C Source/Components/GAMs/PulseGeneratorGAM -f Makefile.gcc clean + $(MAKE) -C Source/Components/GAMs/SlowControlGAM -f Makefile.gcc clean $(MAKE) -C Source/Components/Interfaces/TCPLogger -f Makefile.gcc clean $(MAKE) -C Source/Components/Interfaces/DebugService -f Makefile.gcc clean $(MAKE) -C Test/Components/DataSources/UDPStreamer -f Makefile.gcc clean diff --git a/Source/Components/GAMs/Makefile.gcc b/Source/Components/GAMs/Makefile.gcc index a04ce22..54e2d21 100644 --- a/Source/Components/GAMs/Makefile.gcc +++ b/Source/Components/GAMs/Makefile.gcc @@ -1,9 +1,13 @@ all: $(MAKE) -C SineArrayGAM -f Makefile.gcc $(MAKE) -C TimeArrayGAM -f Makefile.gcc + $(MAKE) -C PulseGeneratorGAM -f Makefile.gcc + $(MAKE) -C SlowControlGAM -f Makefile.gcc clean: $(MAKE) -C SineArrayGAM -f Makefile.gcc clean $(MAKE) -C TimeArrayGAM -f Makefile.gcc clean + $(MAKE) -C PulseGeneratorGAM -f Makefile.gcc clean + $(MAKE) -C SlowControlGAM -f Makefile.gcc clean .PHONY: all clean diff --git a/Source/Components/Interfaces/UDPStream/UDPSServer.cpp b/Source/Components/Interfaces/UDPStream/UDPSServer.cpp index a273e56..8c6463d 100644 --- a/Source/Components/Interfaces/UDPStream/UDPSServer.cpp +++ b/Source/Components/Interfaces/UDPStream/UDPSServer.cpp @@ -18,6 +18,10 @@ namespace MARTe { +/* Out-of-line definitions for class-scope static const members that are + * ODR-used (e.g. passed through varargs in REPORT_ERROR). */ +const uint32 UDPSServer::UDPS_SERVER_MAX_UDP_PAYLOAD; + // --------------------------------------------------------------------------- // Constructor / Destructor // --------------------------------------------------------------------------- @@ -88,6 +92,16 @@ bool UDPSServer::Initialise(StructuredDataI &data) { uint32 mps = UDPS_SERVER_DEFAULT_MAX_PAYLOAD; (void)data.Read("MaxPayloadSize", mps); + /* A payload larger than the largest legal UDP datagram cannot be sent — + * sendto would fail with EMSGSIZE and the whole cycle would be dropped. */ + if (mps > UDPS_SERVER_MAX_UDP_PAYLOAD) { + REPORT_ERROR_STATIC(ErrorManagement::Warning, + "UDPSServer: MaxPayloadSize %u exceeds the UDP datagram " + "limit (%u); clamping to %u.", + mps, UDPS_SERVER_MAX_UDP_PAYLOAD, + UDPS_SERVER_MAX_UDP_PAYLOAD); + mps = UDPS_SERVER_MAX_UDP_PAYLOAD; + } maxPayloadSize = mps; uint32 timeoutSecs = UDPS_SERVER_DEFAULT_CLIENT_TIMEOUT_S; diff --git a/Source/Components/Interfaces/UDPStream/UDPSServer.h b/Source/Components/Interfaces/UDPStream/UDPSServer.h index 8de04d7..b63792f 100644 --- a/Source/Components/Interfaces/UDPStream/UDPSServer.h +++ b/Source/Components/Interfaces/UDPStream/UDPSServer.h @@ -58,6 +58,11 @@ public: /** Default maximum UDP payload size (bytes, EXCLUDING the 17-byte header). */ static const uint32 UDPS_SERVER_DEFAULT_MAX_PAYLOAD = 1400u; + /** Largest legal UDP payload: IPv4 datagrams cap at 65507 bytes, of which + * 17 are the UDPS header. A larger MaxPayloadSize makes every sendto fail + * with EMSGSIZE, so the configured value is clamped to this. */ + static const uint32 UDPS_SERVER_MAX_UDP_PAYLOAD = 65507u - UDPS_HEADER_SIZE; + UDPSServer(); ~UDPSServer(); diff --git a/run_udp_producer.sh b/run_udp_producer.sh index a097a3f..e4209c2 100755 --- a/run_udp_producer.sh +++ b/run_udp_producer.sh @@ -4,28 +4,40 @@ # A producer only: no StreamHub, no clients. Point whatever consumer you like at # the UDP port (StreamHub, the Go hub, or Test/E2E tooling). # -# Each channel is a 1000-element float32 array published every 1 ms by a 1 kHz -# real-time thread — 1000 samples x 1000 Hz = 1 Msps per channel. A parallel -# uint64 time array gives every sample its own timestamp (TimeMode=FullArray), -# so consumers reconstruct the waveform at full rate rather than one point per -# cycle. +# Each channel is an N-element float32 array published every cycle by a +# real-time thread — 10000 samples x 100 Hz = 1 Msps per channel by default. A +# parallel uint64 time array gives every sample its own timestamp +# (TimeMode=FullArray), so consumers reconstruct the waveform at full rate +# rather than one point per cycle. When a cycle exceeds the UDP datagram limit +# it is split across several datagrams (fragmented) and reassembled by the +# receiver. +# +# One extra channel, HV, emulates a charged-capacitor pulse discharge on a +# high-voltage bus: an emulated EPICS "start" setpoint (SlowControlGAM) raises +# a rising edge, on which PulseGeneratorGAM ramps the output to -40 kV in 1 ms, +# holds it flat for the emulated EPICS "duration" setpoint, then discharges +# back to 0 over 100 ms. Always-on gaussian noise (3-sigma ~ +-1000 V) and +# random +-5000 V EMI spikes (on and off phase) ride on top. # # Usage: # ./run_udp_producer.sh [OPTIONS] # # Options: -# -n Number of 1 Msps channels (default 4, max 13 — see below) +# -n Number of 1 Msps sine channels (default 4, max 12 — see below) # -p UDP port to stream on (default 44501) +# -t HV trigger period (time between pulses, default 5000) +# -d HV plateau duration (EPICS "duration" setpoint, default 500) # -b Build target (default: $TARGET or x86-linux) # -s Skip the component rebuild # -k Keep the generated .cfg on exit and print its path # -h Show this help # -# Why 13 channels max: one cycle is TimeArray(8000 B) + CHANNELS x 4000 B, and -# it is sent as a single datagram to keep the receiver's fragment-reassembly -# pool from evicting in-flight cycles (which shows up as periodic gaps in the -# trace). A UDP datagram tops out at 65507 B, so 8000 + 4000*13 + headroom fits -# and 14 does not. +# Channels are capped at 12 as a practical bound on the bytes the background +# thread copies and sends each cycle (the uint64 time array plus (CHANNELS+1) +# float32 arrays). Cycles larger than one UDP datagram are fragmented into +# 60000 B datagrams and reassembled by the receiver by sequence counter, so the +# datagram limit no longer caps the channel count — only the per-cycle send +# budget does. # # Environment: # MARTe2_DIR must be set (or source env.sh first) @@ -39,63 +51,87 @@ CHANNELS=4 PORT=44501 SKIP_BUILD=0 KEEP_CFG=0 +HV_TRIG_PERIOD=5000 +HV_PLATEAU_MS=500 -MAX_CHANNELS=13 +MAX_CHANNELS=12 -while getopts "n:p:b:skh" opt; do - case "$opt" in - n) CHANNELS="$OPTARG" ;; - p) PORT="$OPTARG" ;; - b) BUILD_TARGET="$OPTARG" ;; - s) SKIP_BUILD=1 ;; - k) KEEP_CFG=1 ;; - h) sed -n '2,33p' "$0" | sed 's/^# \?//'; exit 0 ;; - *) echo "Unknown option: -$OPTARG" >&2; exit 1 ;; - esac +while getopts "n:p:t:d:b:skh" opt; do + case "$opt" in + n) CHANNELS="$OPTARG" ;; + p) PORT="$OPTARG" ;; + t) HV_TRIG_PERIOD="$OPTARG" ;; + d) HV_PLATEAU_MS="$OPTARG" ;; + b) BUILD_TARGET="$OPTARG" ;; + s) SKIP_BUILD=1 ;; + k) KEEP_CFG=1 ;; + h) + sed -n '2,43p' "$0" | sed 's/^# \?//' + exit 0 + ;; + *) + echo "Unknown option: -$OPTARG" >&2 + exit 1 + ;; + esac done # ── Validate ────────────────────────────────────────────────────────────────── -if ! [[ "$CHANNELS" =~ ^[0-9]+$ ]] || (( CHANNELS < 1 || CHANNELS > MAX_CHANNELS )); then - echo "ERROR: -n must be 1..${MAX_CHANNELS} (got '${CHANNELS}')." >&2 - exit 1 +if ! [[ "$CHANNELS" =~ ^[0-9]+$ ]] || ((CHANNELS < 1 || CHANNELS > MAX_CHANNELS)); then + echo "ERROR: -n must be 1..${MAX_CHANNELS} (got '${CHANNELS}')." >&2 + exit 1 fi if [[ -z "${MARTe2_DIR:-}" || -z "${MARTe2_Components_DIR:-}" ]]; then - echo "ERROR: MARTe2_DIR / MARTe2_Components_DIR not set." >&2 - echo " source ${SCRIPT_DIR}/env.sh" >&2 - exit 1 + echo "ERROR: MARTe2_DIR / MARTe2_Components_DIR not set." >&2 + echo " source ${SCRIPT_DIR}/env.sh" >&2 + exit 1 fi MARTE2_BIN="${MARTe2_DIR}/Build/${BUILD_TARGET}/App/MARTeApp.ex" if [[ ! -x "$MARTE2_BIN" ]]; then - echo "ERROR: MARTeApp.ex not found at ${MARTE2_BIN}" >&2 - exit 1 + echo "ERROR: MARTeApp.ex not found at ${MARTE2_BIN}" >&2 + exit 1 fi # ── Build ───────────────────────────────────────────────────────────────────── if [[ "$SKIP_BUILD" -eq 0 ]]; then - echo "==> Building components (TARGET=${BUILD_TARGET})..." - make -C "${SCRIPT_DIR}" -f Makefile.gcc TARGET="${BUILD_TARGET}" core 2>&1 | tail -5 + echo "==> Building components (TARGET=${BUILD_TARGET})..." + make -C "${SCRIPT_DIR}" -f Makefile.gcc TARGET="${BUILD_TARGET}" core 2>&1 | tail -5 fi # ── Generate the config ─────────────────────────────────────────────────────── # Distinct amplitude/frequency/phase per channel so traces stay tellable apart # (and so a shared-Y-axis view has a spread of magnitudes to cope with). -AMPS=(1.0 2.5 0.5 5.0 1.5 3.0 0.8 4.0 2.0 0.3 6.0 1.2 3.5) -FREQS=(1000 2000 5000 500 10000 3000 20000 1500 7000 50000 800 4000 15000) +AMPS=(1.0 2.5 0.5 5.0 1.5 3.0 0.8 4.0 2.0 0.3 6.0 1.2 3.5) +FREQS=(1000 2000 5000 500 10000 3000 20000 1500 7000 50000 800 4000 15000) PHASES=(0.0 0.7854 1.5708 2.3562 3.1416 3.9270 4.7124 5.4978 0.3927 1.1781 1.9635 2.7489 3.5343) -ELEMS=1000 # samples per cycle -RATE=1000 # cycles per second -> 1 Msps -CYCLE_BYTES=$(( 8 * ELEMS + CHANNELS * 4 * ELEMS )) -PAYLOAD=$(( CYCLE_BYTES + 2000 )) # headroom for header + descriptors +ELEMS=10000 # samples per cycle +RATE=100 # cycles per second -> 1 Msps +CYCLE_BYTES=$((8 * ELEMS + (CHANNELS + 1) * 4 * ELEMS + 8)) +# UDP datagrams cap at 65507 B (IPv4) minus the 17 B UDPS header; keep a margin +# and use 60000 B of payload per fragment. +MAX_UDP_PAYLOAD=60000 +if ((CYCLE_BYTES + 2000 <= MAX_UDP_PAYLOAD)); then + PAYLOAD=$((CYCLE_BYTES + 2000)) # whole cycle in one datagram +else + PAYLOAD=${MAX_UDP_PAYLOAD} # fragment the cycle across datagrams +fi +FRAGMENTS=$(((CYCLE_BYTES + MAX_UDP_PAYLOAD - 1) / MAX_UDP_PAYLOAD)) -sine_gams=""; iogam_in=""; iogam_out=""; stream_sigs=""; func_list="TimerGAM" +sine_gams="" +iogam_in="" +iogam_out="" +stream_sigs="" +func_list="TimerGAM" -for (( i = 1; i <= CHANNELS; i++ )); do - k=$(( i - 1 )) - amp="${AMPS[$k]}"; frq="${FREQS[$k]}"; pha="${PHASES[$k]}" +for ((i = 1; i <= CHANNELS; i++)); do + k=$((i - 1)) + amp="${AMPS[$k]}" + frq="${FREQS[$k]}" + pha="${PHASES[$k]}" - sine_gams+=" + sine_gams+=" +SineGAM${i} = { Class = SineArrayGAM Frequency = ${frq}.0 @@ -113,21 +149,21 @@ for (( i = 1; i <= CHANNELS; i++ )); do } } " - iogam_in+=" + iogam_in+=" Ch${i} = { DataSource = DDB1 Type = float32 NumberOfDimensions = 1 NumberOfElements = ${ELEMS} }" - iogam_out+=" + iogam_out+=" Ch${i} = { DataSource = Streamer Type = float32 NumberOfDimensions = 1 NumberOfElements = ${ELEMS} }" - stream_sigs+=" + stream_sigs+=" Ch${i} = { Type = float32 Unit = \"V\" @@ -138,13 +174,113 @@ for (( i = 1; i <= CHANNELS; i++ )); do TimeMode = \"FullArray\" TimeSignal = TimeArray }" - func_list+=", SineGAM${i}" + func_list+=", SineGAM${i}" done -func_list+=", TimeArrayGAM1, StreamerGAM" +hv_gams=" + +SlowControlGAM1 = { + Class = SlowControlGAM + TriggerPeriodMs = ${HV_TRIG_PERIOD} + TriggerWidthMs = 10 + PlateauMs = ${HV_PLATEAU_MS} + CycleFrequency = ${RATE} + OutputSignals = { + HVTrigger = { + DataSource = DDB1 + Type = float32 + } + HVPlateauMs = { + DataSource = DDB1 + Type = float32 + } + } + } + + +PulseGeneratorGAM1 = { + Class = PulseGeneratorGAM + SamplingRate = 1000000.0 + RampUpMs = 1.0 + RampDownMs = 100.0 + HighLevel = -40000.0 + NoiseStdDev = 333.33 + EMIAmplitude = 5000.0 + EMIProbabilityPerSample = 0.00001 + EMISpikeSamples = 5 + AutoTriggerPeriodMs = 0.0 + InputSignals = { + HVTrigger = { + DataSource = DDB1 + Type = float32 + } + HVPlateauMs = { + DataSource = DDB1 + Type = float32 + } + } + OutputSignals = { + HV = { + DataSource = DDB1 + Type = float32 + NumberOfDimensions = 1 + NumberOfElements = ${ELEMS} + } + } + } +" + +iogam_in+=" + HV = { + DataSource = DDB1 + Type = float32 + NumberOfDimensions = 1 + NumberOfElements = ${ELEMS} + } + HVTrigger = { + DataSource = DDB1 + Type = float32 + } + HVPlateauMs = { + DataSource = DDB1 + Type = float32 + }" +iogam_out+=" + HV = { + DataSource = Streamer + Type = float32 + NumberOfDimensions = 1 + NumberOfElements = ${ELEMS} + } + HVTrigger = { + DataSource = Streamer + Type = float32 + } + HVPlateauMs = { + DataSource = Streamer + Type = float32 + }" +stream_sigs+=" + HV = { + Type = float32 + Unit = \"V\" + NumberOfDimensions = 1 + NumberOfElements = ${ELEMS} + RangeMin = -50000.0 + RangeMax = 10000.0 + TimeMode = \"FullArray\" + TimeSignal = TimeArray + } + HVTrigger = { + Type = float32 + Unit = \"1\" + } + HVPlateauMs = { + Type = float32 + Unit = \"ms\" + }" +func_list+=", TimeArrayGAM1, SlowControlGAM1, PulseGeneratorGAM1, StreamerGAM" CFG="$(mktemp /tmp/udp_producer_XXXXXX.cfg)" -cat > "$CFG" <"$CFG" < "$CFG" < Config kept at ${CFG}" - else - rm -f "$CFG" - fi + if [[ "$KEEP_CFG" -eq 1 ]]; then + echo "" + echo "==> Config kept at ${CFG}" + else + rm -f "$CFG" + fi } trap cleanup EXIT INT TERM echo "" echo "==> Streaming on udp/${PORT}" echo " Channels : ${CHANNELS} x 1 Msps (${ELEMS} elem @ ${RATE} Hz)" -for (( i = 1; i <= CHANNELS; i++ )); do - k=$(( i - 1 )) - printf ' Ch%-2d %8s Hz %s V\n' "$i" "${FREQS[$k]}" "${AMPS[$k]}" +for ((i = 1; i <= CHANNELS; i++)); do + k=$((i - 1)) + printf ' Ch%-2d %8s Hz %s V\n' "$i" "${FREQS[$k]}" "${AMPS[$k]}" done -echo " Cycle : ${CYCLE_BYTES} B (MaxPayloadSize ${PAYLOAD})" +echo " HV pulse: -40000 V, ramp up 1 ms, flat ${HV_PLATEAU_MS} ms, discharge 100 ms" +echo " noise +-1000 V (3 sigma), EMI +-5000 V, trigger every ${HV_TRIG_PERIOD} ms" +echo " Cycle : ${CYCLE_BYTES} B (${FRAGMENTS} x ${PAYLOAD} B datagram)" echo " Config : ${CFG}" echo "" echo " Consume with e.g.:" @@ -322,7 +461,7 @@ echo " Press Ctrl-C to stop." echo "" exec "${MARTE2_BIN}" \ - -l RealTimeLoader \ - -f "${CFG}" \ - -s Running \ - -m StateMachine:START + -l RealTimeLoader \ + -f "${CFG}" \ + -s Running \ + -m StateMachine:START