// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 The Sargassum Training Kit Authors /* ── Wave-tank tab: live bench calibration ── A bench rig drives a physical wave maker at a commanded height/period; the board reports back its own significant-wave-height (Hs) and peak-period (Tp) estimates over a WebSocket. This tab overlays the board's live estimates against the operator's commanded ground truth so the on-board estimator can be tuned in the loop. TWO CLOCKS collapse to one here: every telemetry frame is stamped with the SERVER receipt time (the board's own clock is untrusted and resets on reboot — the server injects `ts` on the /ws/board hop), so the whole tab plots that single server `ts`. Live frames arrive on /ws/ui; history is seeded once on mount from /api/wave-readings so the charts are populated before the socket's first frame. Charts are hand-rolled inline SVG (no chart lib): a fixed 1000-wide viewBox scaled to the container via preserveAspectRatio="none", so no element measurement / ResizeObserver is needed. The presentation — info tooltips, the stat grid, the hover cursor/tooltip on the charts, and the param rows — is the cleaned-up console styling; the numbers behind it are all real board telemetry. */ const { useState, useEffect, useRef, useCallback, useMemo } = React; const READINGS_CAP = 7200; // ~1 h at 2 Hz const HEAVE_CAP = 1200; // ~2 min at 10 Hz (5 heave samples per 2 Hz frame) const HEAVE_WINDOW = 600; // last ~60 s drawn in the heave strip const RECONNECT_MS = 2000; const ACK_KEEP = 40; // ack ring; the log renders the last 8 // sarg verdict codes reported in every frame's `sarg.c`. const SARG_VERDICT = { 0: { label: 'OPEN WATER', cls: 'neutral' }, 1: { label: 'IN MAT', cls: 'green' }, 2: { label: 'OUT OF WATER', cls: 'wine' }, 255: { label: 'WARMING UP', cls: 'neutral' }, }; // Normalize a seeded REST row ({ts, hs_mm, tp_ds, raw:{…board fields}}) into the same flat shape a live WS // `reading` frame carries, keyed on the SERVER ts so seeded + live frames plot on one timeline. function normReading(row) { return { ...(row.raw || {}), ts: row.ts, hs_mm: row.hs_mm, tp_ds: row.tp_ds }; } function ringPush(arr, item, cap) { const next = arr.concat([item]); return next.length > cap ? next.slice(next.length - cap) : next; } function ringExtend(arr, items, cap) { const next = arr.concat(items); return next.length > cap ? next.slice(next.length - cap) : next; } // ── live socket: seed history, then open /ws/ui and append. Auto-reconnects on close. ── function useWaveSocket(drifter) { const [readings, setReadings] = useState([]); const [heave, setHeave] = useState([]); const [runs, setRuns] = useState([]); const [connected, setConnected] = useState(false); const [pending, setPending] = useState(0); const [acks, setAcks] = useState([]); const wsRef = useRef(null); // The readings cap grows when a wider history window is loaded (loadHistory below) — a fixed cap // would let live appends immediately trim freshly-loaded history back down. const capRef = useRef(READINGS_CAP); const loadedSinceRef = useRef(Date.now() / 1000 - 3600); // seed fetch below covers the last hour const appendReading = useCallback((r) => { setReadings(prev => ringPush(prev, r, capRef.current)); if (Array.isArray(r.heave) && r.heave.length) { setHeave(prev => ringExtend(prev, r.heave, HEAVE_CAP)); } }, []); // Load older history on demand (the range selector asked for a wider window than is in memory). // Fetches the whole window newest-first from the server, then splices any live frames that arrived // during the fetch back on the end — so the charts show real history, not just since-page-load. const loadHistory = useCallback(async (minutes) => { const since = Date.now() / 1000 - minutes * 60; if (since >= loadedSinceRef.current) return; // already loaded at least this far back loadedSinceRef.current = since; const cap = Math.ceil(minutes * 60 * 2 * 1.2) + 600; // 2 Hz + margin for cadence jitter if (cap > capRef.current) capRef.current = cap; const rows = await API.getWaveReadings(drifter, since, cap); const norm = rows.map(normReading); setReadings(prev => { const lastTs = norm.length ? norm[norm.length - 1].ts : 0; const newer = prev.filter(r => r.ts > lastTs); const merged = norm.concat(newer); return merged.length > capRef.current ? merged.slice(merged.length - capRef.current) : merged; }); }, [drifter]); useEffect(() => { let closed = false; const dispatch = (raw) => { let msg; try { msg = JSON.parse(raw); } catch (e) { return; } switch (msg.type) { case 'reading': appendReading(msg); break; case 'ack': setAcks(prev => ringPush(prev, { ...msg, at: Date.now() }, ACK_KEEP)); break; case 'board': setConnected(!!msg.connected); break; case 'pending': setPending(msg.count || 0); break; default: break; } }; const connect = () => { if (closed) return; const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const url = `${proto}://${location.host}/ws/ui?drifter=${encodeURIComponent(drifter)}&token=${encodeURIComponent(TOKEN)}`; const ws = new WebSocket(url); wsRef.current = ws; ws.onmessage = (ev) => dispatch(ev.data); ws.onclose = () => { if (closed) return; setConnected(false); setTimeout(connect, RECONNECT_MS); // no-op if unmounted (closed guard prevents duplicate connect) }; ws.onerror = () => { try { ws.close(); } catch (e) { /* onclose handles reconnect */ } }; }; // seed history first, THEN open the live socket so the initial replace can't clobber a live frame (async () => { const [rows, r] = await Promise.all([ API.getWaveReadings(drifter, Date.now() / 1000 - 3600), API.getWaveRuns(drifter), ]); if (closed) return; const norm = rows.map(normReading); setReadings(norm.slice(-READINGS_CAP)); const flat = []; for (const rr of norm) if (Array.isArray(rr.heave)) for (const h of rr.heave) flat.push(h); setHeave(flat.slice(-HEAVE_CAP)); setRuns(r); connect(); })(); return () => { closed = true; if (wsRef.current) { try { wsRef.current.close(); } catch (e) { /* unmounting */ } } }; }, [drifter, appendReading]); // Poll the run list so runs started/stopped OUTSIDE this page (the wave-command API, another // browser tab) show up without a reload — the live WS carries readings/acks but has no // run-change event, so without this the badge + ground-truth overlay go stale-until-reload. useEffect(() => { const t = setInterval(() => { API.getWaveRuns(drifter).then(setRuns).catch(() => { /* transient; next tick retries */ }); }, 8000); return () => clearInterval(t); }, [drifter]); // refetch runs after a start/stop so the badge + chart overlay reflect it immediately (the server owns // run rows; the board only echoes the start-run command). const refreshRuns = useCallback(async () => { setRuns(await API.getWaveRuns(drifter)); }, [drifter]); return { readings, heave, runs, connected, pending, acks, refreshRuns, loadHistory }; } // ───────────────────────────────────────────────────────────── // INFO BADGE — explanatory tooltip on hover/focus // ───────────────────────────────────────────────────────────── function WtInfo({ tip }) { return ( i{tip} ); } // ───────────────────────────────────────────────────────────── // HEAVE STRIP — last ~60 s of raw 10 Hz vertical accel // ───────────────────────────────────────────────────────────── function HeaveStrip({ samples }) { const W = 1000, H = 120, padT = 8, padB = 8; const win = samples.slice(-HEAVE_WINDOW); // ±0.5 m/s² minimum half-span so a calm sea doesn't blow the scale up to noise. let maxAbs = 0; for (const v of win) { const a = Math.abs(v); if (a > maxAbs) maxAbs = a; } const half = Math.max(0.5, maxAbs); const yOf = (v) => { const mid = (H - padT - padB) / 2 + padT; return mid - (v / half) * ((H - padT - padB) / 2); }; const xOf = (i) => win.length <= 1 ? 0 : (i / (win.length - 1)) * W; const pts = win.map((v, i) => `${xOf(i).toFixed(1)},${yOf(v).toFixed(1)}`).join(' '); const zeroY = yOf(0).toFixed(1); return (
Heave · raw vertical accel (10 Hz) ±{half.toFixed(2)} m/s²
{win.length > 1 && ( )} {win.length <= 1 && ( waiting for heave… )}
); } // ───────────────────────────────────────────────────────────── // Hs / Tp CHART — two stacked panels on a shared session-time x-axis // ───────────────────────────────────────────────────────────── const CHART_W = 1000; const RANGES = [['3m', 3], ['10m', 10], ['30m', 30], ['1h', 60]]; // Relative x-axis label for a "minutes back from now" tick (e.g. -30m). Integers render clean; a custom // fractional window keeps one decimal. function fmtRange(min) { if (min <= 0) return 'now'; const m = min >= 10 ? Math.round(min) : Math.round(min * 10) / 10; return `-${m}m`; } // Relative hover label from a reading's age in seconds: "now" at the leading edge, "-m:ss ago" behind it. function fmtAgo(secAgo) { secAgo = Math.max(0, Math.round(secAgo)); if (secAgo === 0) return 'now'; const m = Math.floor(secAgo / 60), s = secAgo % 60; return `-${m}:${String(s).padStart(2, '0')} ago`; } // One panel: an off-SVG y-axis column + a hover-tracked plot with faint y-grid, run-span shading, a // dashed commanded ground-truth overlay held flat across each run's [started, stopped||now] span, and the // board estimate as a gapped polyline (valueOf() returns null wherever there is no value — Tp==0 gated = // a GAP, NOT a plunge to zero). The x-axis maps the real server `ts`; hover snaps to the nearest reading // and reports both the board estimate and the commanded value at that instant. function ChartPanel({ label, unit, tip, readings, runs, tMin, tMax, now, windowMin, valueOf, truthOf, fmt, yMinFloor, calValueOf, calLabel }) { const H = 180, padT = 12, padB = 10; const [hover, setHover] = useState(null); const span = tMax > tMin ? tMax - tMin : 1; const xOf = (ts) => ((ts - tMin) / span) * CHART_W; const cx = (x) => Math.max(0, Math.min(CHART_W, x)); // clamp run overlay to the visible window // y-domain over the board series, the calibrated series, and the commanded truth so none clip. let vMax = yMinFloor; for (const r of readings) { const v = valueOf(r); if (v != null && v > vMax) vMax = v; } if (calValueOf) for (const r of readings) { const v = calValueOf(r); if (v != null && v > vMax) vMax = v; } for (const run of runs) { const v = truthOf(run); if (v != null && v > vMax) vMax = v; } vMax = vMax * 1.15 || 1; const yOf = (v) => (H - padB) - (v / vMax) * (H - padT - padB); // a series (board estimate, or the calibrated map of it), broken into segments wherever the // accessor returns null. const segsOf = (accessor) => { const out = []; let run = []; for (const r of readings) { const v = accessor(r); if (v == null) { if (run.length) { out.push(run); run = []; } continue; } run.push(`${xOf(r.ts).toFixed(1)},${yOf(v).toFixed(1)}`); } if (run.length) out.push(run); return out; }; const segs = segsOf(valueOf); const calSegs = calValueOf ? segsOf(calValueOf) : []; // commanded value active at a given server ts (the run whose span brackets it), or null. const commandedAt = (ts) => { let v = null; for (const run of runs) { const s = run.started_ts, e = run.stopped_ts != null ? run.stopped_ts : now; if (ts >= s && ts <= e) { const tv = truthOf(run); if (tv != null) v = tv; } } return v; }; const onMove = (e) => { if (!readings.length) return; const rect = e.currentTarget.getBoundingClientRect(); const f = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const tsHover = tMin + f * span; // snap to the nearest reading so the cursor + dot land on real data let best = readings[0], bd = Math.abs(readings[0].ts - tsHover); for (const r of readings) { const d = Math.abs(r.ts - tsHover); if (d < bd) { bd = d; best = r; } } const estVal = valueOf(best); setHover({ xPct: (xOf(best.ts) / CHART_W) * 100, agoLabel: fmtAgo(now - best.ts), estVal, estYPct: estVal != null ? (yOf(estVal) / H) * 100 : null, cmdVal: commandedAt(best.ts), calVal: calValueOf ? calValueOf(best) : null, }); }; const yTicks = [vMax, vMax / 2, 0]; return (
{label} {unit} {tip && } board estimate {calValueOf && {calLabel || 'calibrated'}} commanded
{yTicks.map((t, i) => {fmt(t)})}
setHover(null)}> {/* y gridlines */} {yTicks.map((t, i) => ( ))} {/* run-span shading (clamped to the visible window) */} {runs.map(run => { const x0 = cx(xOf(run.started_ts)); const x1 = cx(xOf(run.stopped_ts != null ? run.stopped_ts : now)); if (x1 <= x0) return null; return ; })} {/* commanded ground-truth: flat dashed across each run's [started, stopped||now] span */} {runs.map(run => { const v = truthOf(run); if (v == null) return null; const x0 = cx(xOf(run.started_ts)); const x1 = cx(xOf(run.stopped_ts != null ? run.stopped_ts : now)); if (x1 <= x0) return null; return ; })} {/* board estimate, gapped */} {segs.map((seg, i) => seg.length >= 2 ? : )} {/* calibrated series (the affine map of the estimate), gapped like the estimate */} {calSegs.map((seg, i) => seg.length >= 2 ? : )} {hover && ( <>
{hover.estYPct != null && (
)}
60 ? 'calc(-100% - 12px)' : '12px'}, -50%)`, top: `${hover.estYPct != null ? hover.estYPct : 50}%` }}>
{hover.agoLabel}
estimate {hover.estVal != null ? `${fmt(hover.estVal)} ${unit}` : '—'}
{calValueOf && (
{calLabel || 'calibrated'} {hover.calVal != null ? `${fmt(hover.calVal)} ${unit}` : '—'}
)}
commanded {hover.cmdVal != null ? `${fmt(hover.cmdVal)} ${unit}` : '—'}
)}
{fmtRange(windowMin)} {fmtRange(windowMin / 2)} now
); } function HsTpChart({ readings, runs, now, loadHistory }) { const [range, setRange] = useState('3m'); const [customMin, setCustomMin] = useState('45'); // Affine height calibration, display-side only: H_cal = (Hs + offset) / slope. The board keeps // reporting RAW spectral Hs (never bake calibration into stored data); this maps it to a // crest-to-trough height comparable with the tank's measured/commanded H. Defaults from the // 2026-07-16 tank height sweep (linear regime 10-25mm): Hs = 1.6·H − 7. Persisted per-browser. const [calSlope, setCalSlope] = useState(() => localStorage.getItem('wt_cal_slope') || '1.6'); const [calOffset, setCalOffset] = useState(() => localStorage.getItem('wt_cal_offset') || '7'); useEffect(() => { localStorage.setItem('wt_cal_slope', calSlope); }, [calSlope]); useEffect(() => { localStorage.setItem('wt_cal_offset', calOffset); }, [calOffset]); const slope = parseFloat(calSlope), offset = parseFloat(calOffset); const calOk = isFinite(slope) && slope > 0 && isFinite(offset); const calHsOf = calOk ? (r) => (typeof r.hs_mm === 'number' && r.hs_mm > 0 ? (r.hs_mm + offset) / slope : null) : null; const sortedRuns = useMemo(() => [...runs].sort((a, b) => a.started_ts - b.started_ts), [runs]); // Commanded (yellow) overlay + run-span shading show ONLY while a bench run is actively running — // a run with no stopped_ts. Once you Stop the run, the ground-truth line clears. (Only one run is // ever active at a time.) const activeRuns = useMemo(() => sortedRuns.filter(r => r.stopped_ts == null), [sortedRuns]); const cm = parseFloat(customMin); const rangeMin = range === 'custom' ? (cm >= 1 ? cm : 30) // guard: <1 or NaN → default 30 : ({ '3m': 3, '10m': 10, '30m': 30, '1h': 60 })[range]; // A wider window than is in memory pulls real history from the server (not just since-page-load). useEffect(() => { if (loadHistory) loadHistory(rangeMin); }, [rangeMin, loadHistory]); if (!readings.length) { return (
Waiting for wave telemetry… start the bench rig and its board.
); } // window the plotted data by REAL server ts (not sample count): the visible domain is the last // `rangeMin` minutes, so the relative x-axis labels (-Nm … now) stay honest as cadence varies or gaps. const tMax = now; const tMin = now - rangeMin * 60; const visible = readings.filter(r => r.ts >= tMin); return (
Time range {RANGES.map(([k]) => ( ))} {range === 'custom' && ( )} Cal H = (Hs + setCalOffset(e.target.value)} style={{ width: 44, textAlign: 'center' }} aria-label="calibration offset (mm)" /> ) / setCalSlope(e.target.value)} style={{ width: 44, textAlign: 'center' }} aria-label="calibration slope" />
v.toFixed(0)} valueOf={r => (typeof r.hs_mm === 'number' ? r.hs_mm : null)} truthOf={run => run.h_mm} calValueOf={calHsOf} calLabel="calibrated H" />
v.toFixed(1)} valueOf={r => (r.tp_ds > 0 ? r.tp_ds / 10 : null)} truthOf={run => (run.t_ds > 0 ? run.t_ds / 10 : null)} />
); } // ───────────────────────────────────────────────────────────── // STATUS + STATS — board presence + link/power/estimator health // ───────────────────────────────────────────────────────────── function WtStat({ label, children, tone }) { return (
{label} {children}
); } function StatusHealth({ reading, connected, lastTs, now }) { const r = reading || {}; const age = lastTs ? Math.max(0, now - lastTs) : null; const promLow = typeof r.prom === 'number' && typeof r.prom_min === 'number' && r.prom < r.prom_min; const verdict = SARG_VERDICT[r.sarg ? r.sarg.c : undefined] || { label: '—', cls: 'neutral' }; return (
{connected ? board online : board offline} {age == null ? 'no frame yet' : `last frame ${age.toFixed(0)}s ago`} {verdict.label}
{r.fill != null && r.wave_n ? `${Math.min(100, Math.round((r.fill / r.wave_n) * 100))}%` : '—'} {r.prom != null ? `${r.prom.toFixed(2)} / ${r.prom_min != null ? r.prom_min : '—'}` : '—'} {r.rssi != null ? `${r.rssi} dBm` : '—'} {r.heap != null ? `${(r.heap / 1024).toFixed(0)}k` : '—'} {r.batt_mv != null ? `${(r.batt_mv / 1000).toFixed(2)}V` : '—'}
{promLow && (
▲ Prominence is below the gate — the peak is too weak to lock a period, so Tp reads 0 (gapped above).
)}
); } // ───────────────────────────────────────────────────────────── // RUN PANEL — bracket a bench session at a commanded height/period // ───────────────────────────────────────────────────────────── function RunPanel({ drifter, runs, onChanged }) { const [hMm, setHMm] = useState('120'); const [tSec, setTSec] = useState('1.5'); const active = runs.find(r => r.stopped_ts == null); // Mirror the ACTIVE run into the (disabled-while-running) inputs, wherever it was started from — // this page, another tab, or the wave-command API. Without this the boxes sit on their hardcoded // defaults (120/1.5) while the pill shows the truth, which reads as a contradiction — and those // stale defaults have already produced one accidental mislabeled run via an untouched Start click. // When the run stops, the fields stay editable holding its values as the seed for the next run. useEffect(() => { if (active) { setHMm(String(active.h_mm)); setTSec(String(active.t_ds / 10)); } }, [active && active.id]); const [busy, setBusy] = useState(false); const start = async () => { const h = parseInt(hMm, 10); const tDs = Math.round(parseFloat(tSec) * 10); // seconds → deciseconds for the wire/board if (!(h > 0) || !(tDs > 0)) return; setBusy(true); await API.startWaveRun(drifter, h, tDs); await onChanged(); setBusy(false); }; const stop = async () => { setBusy(true); await API.stopWaveRun(drifter); await onChanged(); setBusy(false); }; return (
Bench run {active ? running · {active.h_mm} mm / {(active.t_ds / 10).toFixed(1)} s : idle}
{active ? : }

Start commands the maker (start-run H T) and brackets this session so the charts can overlay the board's estimate against what you commanded.

Board screen
); } // ───────────────────────────────────────────────────────────── // PARAM PANEL — remote-tune the board's on-board wave estimator // ───────────────────────────────────────────────────────────── const WAVE_N_OPTS = [1024, 2048, 4096]; const TP_N_OPTS = [5, 9, 15]; function ackMatches(ack, key, cmd) { const c = ack && ack.cmd; if (!c) return false; return c === cmd || (c.indexOf('set-param') !== -1 && c.indexOf(key) !== -1); } function WtParam({ label, keyname, boardVal, control, pending, onSet, tip }) { return (
{label} ({keyname}) {tip && }
board: {boardVal == null ? '—' : boardVal}
{control} {pending && pending}
); } function ParamPanel({ drifter, reading, acks }) { const cur = reading || {}; const [waveN, setWaveN] = useState(2048); const [tpN, setTpN] = useState(9); const [promMin, setPromMin] = useState(''); const [flo, setFlo] = useState(''); const [fhi, setFhi] = useState(''); const [pending, setPending] = useState({}); // key -> sent cmd string const lastAckAt = useRef(0); // clear a pending badge when a matching ack lands (each ack arrives once; only inspect the newest). useEffect(() => { const last = acks[acks.length - 1]; if (!last || last.at === lastAckAt.current) return; lastAckAt.current = last.at; setPending(p => { const next = { ...p }; let changed = false; for (const key of Object.keys(next)) { if (ackMatches(last, key, next[key])) { delete next[key]; changed = true; } } return changed ? next : p; }); }, [acks]); const doSet = async (key, value) => { if (value === '' || value == null) return; const cmd = `set-param ${key} ${value}`; setPending(p => ({ ...p, [key]: cmd })); await API.sendWaveCommand(drifter, cmd); }; const recent = acks.slice(-8).reverse(); return (
Board params · retune the on-board estimator live
doSet('wave_n', waveN)} tip="Number of samples per FFT. Larger windows give finer frequency (period) resolution but respond more slowly to change." control={ } /> doSet('tp_n', tpN)} tip="Length of the median filter smoothing the peak-period output. More taps steadies Tp but adds lag." control={ } /> doSet('prom_min', promMin)} tip="Gate on spectral peak strength. A peak weaker than this is ignored and Tp reads 0 — raise it to reject noise, lower it to lock onto faint swell." control={ setPromMin(e.target.value)} />} /> doSet('flo', flo)} tip="Low edge of the band-pass, in hertz. Frequencies below this are discarded — trims drift and slow tilt." control={ setFlo(e.target.value)} />} /> doSet('fhi', fhi)} tip="High edge of the band-pass, in hertz. Frequencies above this are discarded — rejects chop and sensor noise." control={ setFhi(e.target.value)} />} />
Ack log
{recent.length === 0 ? no acks yet : recent.map((a, i) => (
{a.cmd || '?'} → {a.reply != null ? String(a.reply) : 'ok'}
))}
); } // ───────────────────────────────────────────────────────────── // TAB // ───────────────────────────────────────────────────────────── function WaveTankTab({ drifter }) { const { readings, heave, runs, connected, pending, acks, refreshRuns, loadHistory } = useWaveSocket(drifter); const [now, setNow] = useState(Date.now() / 1000); useEffect(() => { // 1 s clock: run spans extend to "now" and the last-frame age counts up const iv = setInterval(() => setNow(Date.now() / 1000), 1000); return () => clearInterval(iv); }, []); const latest = readings.length ? readings[readings.length - 1] : null; const lastTs = latest ? latest.ts : null; return (
08 Wave tank live bench calibration — estimate vs commanded ground truth {pending > 0 && ( {pending} cmd{pending === 1 ? '' : 's'} queued (board offline) )}
); } window.WaveTankTab = WaveTankTab;