// 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²
);
}
// ─────────────────────────────────────────────────────────────
// 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 (
);
}
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" />
▲
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)} />} />