feat: v0.15.0
### Added
- **Layered event storage architecture.** Each event now lands as four
files in the per-serial waveform store, each with a clear role:
- `<filename>` — the Blastware-readable binary (BW file). Untouched.
- `<filename>.a5.pkl` — the raw 5A frames (regenerative source).
- `<filename>.h5` — clean per-channel waveform arrays in physical
units (in/s for geo, psi for mic) plus event metadata (HDF5 with
gzip compression). This is the canonical format for downstream
analysis tools.
- `<filename>.sfm.json` — the modern review/metadata sidecar (peaks,
project, source provenance, review state, extensions).
SQLite (`seismo_relay.db`) is the searchable index over all four.
- **Plot-ready waveform JSON (`sfm.plot.v1`).** The `/device/event/{idx}/waveform`
and `/db/events/{id}/waveform.json` endpoints now return samples in
physical units with explicit time-axis metadata, peak markers, and
per-channel unit hints — no more guessing the ADC-to-velocity scale
client-side. The webapp waveform viewer was rewritten to consume
this shape.
- **In-app waveform viewer accuracy fix.** The standalone SFM webapp
viewer was scaling geophone amplitudes by `geoAdcScale / 32767`
(≈ 6.206 / 32767), where `geoAdcScale = 6.206053` is the device's
*in/s per V* hardware constant — not the ADC-counts-to-velocity
factor. This silently scaled every plot ~38% too low for Normal-range
geophones (the correct full-scale is 10.0 in/s, or 1.25 in/s for
Sensitive). Conversion is now done server-side using the geo_range
from compliance config; the client just plots.
- New `sfm/event_hdf5.py` module: `write_event_hdf5()`,
`read_event_hdf5()`, plus a plot-JSON helper.
- Backfill script extended to also emit `.h5` for existing events.
### Dependencies
- Added `h5py>=3.10` and `numpy>=1.24` for the HDF5 storage layer.
- Added `python-multipart>=0.0.7` (required by FastAPI for the
`/db/import/blastware_file` endpoint introduced in this release).
This commit is contained in:
+565
-60
@@ -639,6 +639,117 @@
|
||||
}
|
||||
.force-toggle.active .ft-dot { background: #f85149; box-shadow: 0 0 6px #f85149; }
|
||||
|
||||
/* ── Sidecar review modal ── */
|
||||
.sc-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.55);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.sc-overlay.visible { display: flex; }
|
||||
.sc-modal {
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
width: min(720px, 92vw);
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
.sc-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.sc-header h3 {
|
||||
margin: 0; font-size: 14px; font-weight: 600;
|
||||
color: var(--text); font-family: monospace;
|
||||
}
|
||||
.sc-close {
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: var(--text-mute); font-size: 18px; line-height: 1;
|
||||
padding: 4px 8px; border-radius: 4px;
|
||||
}
|
||||
.sc-close:hover { background: var(--surface); color: var(--text); }
|
||||
.sc-body {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 16px 18px;
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
}
|
||||
.sc-section {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.sc-section h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
color: var(--text-mute); text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
.sc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sc-grid dt { color: var(--text-mute); }
|
||||
.sc-grid dd { margin: 0; color: var(--text); font-family: monospace; word-break: break-all; }
|
||||
.sc-row { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.sc-row label { color: var(--text-dim); }
|
||||
.sc-row input[type="checkbox"] { cursor: pointer; }
|
||||
.sc-row input[type="text"], .sc-body textarea {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
padding: 6px 9px;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
font-family: monospace;
|
||||
}
|
||||
.sc-body textarea {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
.sc-raw {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.sc-raw summary {
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
.sc-raw pre {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sc-footer {
|
||||
display: flex; justify-content: flex-end; gap: 8px;
|
||||
padding: 12px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.sc-status {
|
||||
flex: 1; align-self: center;
|
||||
font-size: 11px; color: var(--text-mute);
|
||||
}
|
||||
.sc-status.error { color: #f85149; }
|
||||
.sc-status.ok { color: #56d364; }
|
||||
table.db-table tbody tr.clickable { cursor: pointer; }
|
||||
table.db-table tbody tr.clickable:hover { background: var(--surface2); }
|
||||
|
||||
/* ── Section containers ── */
|
||||
#section-live, #section-db {
|
||||
display: flex;
|
||||
@@ -806,6 +917,14 @@
|
||||
|
||||
<div class="event-toolbar">
|
||||
<button class="btn btn-ghost" id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
|
||||
<button class="btn btn-ghost" id="save-btn" onclick="saveEventToDb()" disabled
|
||||
title="Download the full waveform from the device and save it to the SFM database + waveform store. Honors the Force refresh toggle.">
|
||||
💾 Save to DB
|
||||
</button>
|
||||
<button class="btn btn-ghost" id="download-btn" onclick="downloadEventFile()" disabled
|
||||
title="Download the Blastware-format event file to your computer (also saves it to the server's database + store).">
|
||||
⬇ Download
|
||||
</button>
|
||||
<button class="btn btn-ghost" id="prev-btn" onclick="stepEvent(-1)" disabled>◀</button>
|
||||
<button class="btn btn-ghost" id="next-btn" onclick="stepEvent(+1)" disabled>▶</button>
|
||||
<div class="event-chips" id="event-chips"></div>
|
||||
@@ -1224,7 +1343,7 @@ let currentEvent = 0;
|
||||
let charts = {};
|
||||
let geoAdcScale = 6.206;
|
||||
const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL
|
||||
const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' };
|
||||
const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', MicL:'#bc8cff' };
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
function api() { return document.getElementById('api-base').value.replace(/\/$/, ''); }
|
||||
@@ -1355,9 +1474,11 @@ async function connectUnit() {
|
||||
|
||||
document.getElementById('device-bar').style.display = 'flex';
|
||||
document.getElementById('monitor-panel').style.display = 'flex';
|
||||
document.getElementById('load-btn').disabled = eventList.length === 0;
|
||||
document.getElementById('prev-btn').disabled = true;
|
||||
document.getElementById('next-btn').disabled = eventList.length <= 1;
|
||||
document.getElementById('load-btn').disabled = eventList.length === 0;
|
||||
document.getElementById('save-btn').disabled = eventList.length === 0;
|
||||
document.getElementById('download-btn').disabled = eventList.length === 0;
|
||||
document.getElementById('prev-btn').disabled = true;
|
||||
document.getElementById('next-btn').disabled = eventList.length <= 1;
|
||||
document.getElementById('cfg-read-btn').disabled = false;
|
||||
document.getElementById('cfg-write-btn').disabled = false;
|
||||
document.getElementById('ch-read-btn').disabled = false;
|
||||
@@ -1857,11 +1978,104 @@ async function loadWaveform() {
|
||||
document.getElementById('load-btn').disabled = false;
|
||||
}
|
||||
|
||||
// ── Persist current event to the SFM database + waveform store ──────────────
|
||||
//
|
||||
// Calls /device/event/{idx}/blastware_file, which on the server side:
|
||||
// 1. Downloads the full waveform from the device (5A bulk stream)
|
||||
// 2. Writes the Blastware-format event file into <db_dir>/waveforms/<serial>/
|
||||
// 3. Writes the .a5.pkl sidecar next to it (so the file can be regenerated)
|
||||
// 4. Upserts a row into seismo_relay.db `events` table (dedup'd on serial+timestamp)
|
||||
//
|
||||
// We discard the response body — the side effects are what we want. The
|
||||
// filename comes back in the Content-Disposition header for confirmation.
|
||||
async function saveEventToDb() {
|
||||
if (!devHost()) { setStatus('Enter device host first.', 'error'); return; }
|
||||
const idx = currentEvent;
|
||||
const btn = document.getElementById('save-btn');
|
||||
btn.disabled = true;
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '⏳ Saving…';
|
||||
setStatus(`Downloading event #${idx} and saving to DB…`, 'loading');
|
||||
|
||||
try {
|
||||
const r = await fetch(`${api()}/device/event/${idx}/blastware_file?${deviceParams()}`);
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.detail || r.statusText);
|
||||
}
|
||||
// Pull the body to completion so the connection releases promptly,
|
||||
// then drop it on the floor — we just want the server-side persist.
|
||||
await r.blob();
|
||||
const filename = parseFilenameFromContentDisposition(r.headers.get('Content-Disposition'))
|
||||
|| `event ${idx}`;
|
||||
setStatus(`Saved ${filename} to database + waveform store`, 'ok');
|
||||
} catch (e) {
|
||||
setStatus(`Save error: ${e.message}`, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = orig;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download the event file to the user's computer ──────────────────────────
|
||||
//
|
||||
// Uses a transient anchor + click trick so the browser surfaces its native
|
||||
// "Save As" / Downloads behaviour. Same backend endpoint as Save to DB —
|
||||
// the file is also persisted to the server store as a side effect.
|
||||
function downloadEventFile() {
|
||||
if (!devHost()) { setStatus('Enter device host first.', 'error'); return; }
|
||||
const idx = currentEvent;
|
||||
const url = `${api()}/device/event/${idx}/blastware_file?${deviceParams()}`;
|
||||
setStatus(`Downloading event #${idx}…`, 'loading');
|
||||
// Hidden iframe avoids navigating away from the SPA. FastAPI's FileResponse
|
||||
// sets Content-Disposition: attachment so the browser saves rather than displays.
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// We can't reliably detect when the browser finishes downloading; show a
|
||||
// soft confirmation immediately. Errors will surface as a download failure
|
||||
// dialog from the browser itself.
|
||||
setTimeout(() => setStatus(`Download started for event #${idx} (also saved server-side)`, 'ok'), 250);
|
||||
}
|
||||
|
||||
function parseFilenameFromContentDisposition(header) {
|
||||
if (!header) return null;
|
||||
// RFC 6266: `attachment; filename="M529LKIQ.7M0W"` (or filename*=UTF-8''…)
|
||||
const m = /filename\*?=(?:UTF-8'')?["']?([^"';]+)["']?/i.exec(header);
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
// renderWaveform consumes the `sfm.plot.v1` JSON shape:
|
||||
// {
|
||||
// schema: "sfm.plot.v1",
|
||||
// time_axis: { sample_rate, pretrig_samples, t0_ms, dt_ms, n_samples, ... },
|
||||
// channels: { Tran|Vert|Long|MicL: { unit, values, peak, peak_t_ms } },
|
||||
// geo_range, geo_full_scale_ips, trigger_ms, peak_values, ...
|
||||
// }
|
||||
//
|
||||
// All sample arrays are already in PHYSICAL UNITS (in/s for geo, psi for
|
||||
// mic) — the server applied the right scaling for the unit's geo_range.
|
||||
// The viewer used to multiply ADC ints by `geoAdcScale / 32767` here,
|
||||
// which silently scaled every plot ~38% too low because `geoAdcScale` is
|
||||
// the in/s-per-V hardware constant, not the ADC-counts-to-velocity
|
||||
// factor. No scaling happens client-side now.
|
||||
function renderWaveform(data) {
|
||||
const sr = data.sample_rate || 1024;
|
||||
const pretrig = data.pretrig_samples || 0;
|
||||
const decoded = data.samples_decoded || 0;
|
||||
const total = data.total_samples || decoded;
|
||||
// Backward-compat shim: if we ever get the legacy shape from a stale
|
||||
// cache, normalise it on the client so the viewer still works.
|
||||
if (!data.schema && data.channels && Array.isArray(data.channels.Tran)) {
|
||||
data = _legacyWaveformToPlotV1(data);
|
||||
}
|
||||
|
||||
const t = data.time_axis || {};
|
||||
const sr = t.sample_rate || 1024;
|
||||
const pretrig = t.pretrig_samples || 0;
|
||||
const total = t.total_samples || t.n_samples || 0;
|
||||
const decoded = t.n_samples || 0;
|
||||
const t0 = t.t0_ms ?? -(pretrig / sr * 1000);
|
||||
const dt = t.dt_ms ?? (1000 / sr);
|
||||
const channels = data.channels || {};
|
||||
|
||||
// Status bar
|
||||
@@ -1869,70 +2083,83 @@ function renderWaveform(data) {
|
||||
bar.innerHTML = '';
|
||||
bar.className = 'ok';
|
||||
const ts = data.timestamp;
|
||||
bar.textContent = ts ? `Event #${data.index} — ${ts.display} ` : `Event #${data.index} `;
|
||||
// Title prefers `index` (live device, 0-based slot on the unit) and
|
||||
// falls back to event_id (DB lookup) when index is absent.
|
||||
const eventLabel = (data.index != null) ? `#${data.index}` : (data.event_id || '');
|
||||
bar.textContent = ts ? `Event ${eventLabel} — ${ts} ` : `Event ${eventLabel} `;
|
||||
addPill(`${data.record_type || '?'}`);
|
||||
addPill(`${sr} sps`);
|
||||
addPill(`${decoded.toLocaleString()} / ${total.toLocaleString()} samples`);
|
||||
addPill(`pretrig ${pretrig}`);
|
||||
addPill(`${data.rectime_seconds ?? '?'} s`);
|
||||
addPill(`${t.rectime_seconds ?? '?'} s`);
|
||||
if (data.geo_range) addPill(`geo: ${data.geo_range} (${data.geo_full_scale_ips} in/s FS)`);
|
||||
|
||||
// Any record_type starting with "Waveform" is a viewable triggered
|
||||
// event (the timestamp-header byte layout varies across firmware but
|
||||
// doesn't change the sample stream). Only block when there's actually
|
||||
// no waveform payload to plot.
|
||||
const isWaveformLike = !!(data.record_type || '').match(/^Waveform/i);
|
||||
if (decoded === 0) {
|
||||
document.getElementById('empty-state').style.display = 'flex';
|
||||
document.getElementById('empty-state').querySelector('p').textContent =
|
||||
data.record_type === 'Waveform'
|
||||
isWaveformLike
|
||||
? 'No samples decoded — check server logs'
|
||||
: `Record type "${data.record_type}" — waveform not supported yet`;
|
||||
: `Record type "${data.record_type}" — not a waveform event`;
|
||||
document.getElementById('charts').style.display = 'none';
|
||||
Object.values(charts).forEach(c => c.destroy()); charts = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2));
|
||||
// Time axis: explicit ms values from t0_ms + i*dt_ms. More precise
|
||||
// than the old (i - pretrig) / sr * 1000 since dt_ms came from the
|
||||
// server with full float precision.
|
||||
const times = Array.from({length: decoded}, (_, i) => (t0 + i * dt).toFixed(2));
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
const chartsDiv = document.getElementById('charts');
|
||||
chartsDiv.style.display = 'flex';
|
||||
chartsDiv.innerHTML = '';
|
||||
Object.values(charts).forEach(c => c.destroy()); charts = {};
|
||||
|
||||
const micPeakPsi = data.peak_values?.micl_psi ?? null;
|
||||
|
||||
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
|
||||
const samples = channels[ch];
|
||||
if (!samples || samples.length === 0) continue;
|
||||
const chData = channels[ch];
|
||||
if (!chData || !chData.values || chData.values.length === 0) continue;
|
||||
|
||||
const isGeo = ch !== 'Mic';
|
||||
let plotData, peakLabel, yUnit, ttFmt, tickFmt;
|
||||
const plotData = chData.values;
|
||||
const unit = chData.unit || (ch === 'MicL' ? 'psi' : 'in/s');
|
||||
const peak = chData.peak;
|
||||
const peakTms = chData.peak_t_ms;
|
||||
|
||||
if (isGeo) {
|
||||
const scale = geoAdcScale / 32767;
|
||||
plotData = samples.map(s => s * scale);
|
||||
// Use the device-recorded peak from the 0C waveform record — authoritative
|
||||
// and matches Blastware. Computing from raw samples can catch rogue
|
||||
// near-full-scale values from decoding artifacts.
|
||||
const peakKey = { Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' }[ch];
|
||||
const devicePeak = data.peak_values?.[peakKey] ?? null;
|
||||
peakLabel = devicePeak != null ? `${devicePeak.toFixed(5)} in/s` : `${Math.max(...plotData.map(Math.abs)).toFixed(5)} in/s`;
|
||||
yUnit = 'in/s';
|
||||
ttFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
|
||||
tickFmt = v => v.toFixed(4);
|
||||
let peakLabel, ttFmt, tickFmt;
|
||||
if (unit === 'psi') {
|
||||
const peakDbl = (peak != null && peak > 0)
|
||||
? 20 * Math.log10(peak / DBL_REF) : -Infinity;
|
||||
peakLabel = `${peakDbl.toFixed(1)} dBL (${peak != null ? peak.toExponential(2) : '—'} psi)`;
|
||||
ttFmt = v => `${v.toExponential(3)} psi`;
|
||||
tickFmt = v => v.toExponential(1);
|
||||
} else {
|
||||
const peakCounts = Math.max(...samples.map(Math.abs));
|
||||
const micScale = (micPeakPsi !== null && peakCounts > 0) ? Math.abs(micPeakPsi) / peakCounts : 1.0;
|
||||
plotData = samples.map(s => s * micScale);
|
||||
const peakPsi = Math.max(...plotData.map(Math.abs));
|
||||
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF) : -Infinity;
|
||||
peakLabel = `${peakDbl.toFixed(1)} dBL`;
|
||||
yUnit = 'psi';
|
||||
ttFmt = v => `${v.toExponential(3)} psi`;
|
||||
tickFmt = v => v.toExponential(1);
|
||||
peakLabel = peak != null ? `${peak.toFixed(5)} in/s` : '—';
|
||||
ttFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
|
||||
tickFmt = v => v.toFixed(4);
|
||||
}
|
||||
|
||||
// Downsample for display when the chart would otherwise have to
|
||||
// rasterise tens of thousands of points. Uses every-Nth — fine for
|
||||
// monthly-summary glance work; analysis tools should use the .h5 file.
|
||||
const MAX_PTS = 4000;
|
||||
let rTimes = times, rData = plotData;
|
||||
let rTimes = times, rData = plotData, peakPlotIdx = -1;
|
||||
if (plotData.length > MAX_PTS) {
|
||||
const step = Math.ceil(plotData.length / MAX_PTS);
|
||||
rTimes = times.filter((_, i) => i % step === 0);
|
||||
rData = plotData.filter((_, i) => i % step === 0);
|
||||
// Try to keep the peak sample from being downsampled away.
|
||||
if (peakTms != null) {
|
||||
const exactIdx = Math.round((peakTms - t0) / dt);
|
||||
if (exactIdx >= 0 && exactIdx < plotData.length) {
|
||||
peakPlotIdx = Math.floor(exactIdx / step);
|
||||
}
|
||||
}
|
||||
} else if (peakTms != null) {
|
||||
peakPlotIdx = Math.round((peakTms - t0) / dt);
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
@@ -1960,27 +2187,94 @@ function renderWaveform(data) {
|
||||
},
|
||||
scales: {
|
||||
x: { type: 'category', ticks: { color:'#484f58', maxTicksLimit:10, maxRotation:0, callback:(v,i) => rTimes[i]+' ms' }, grid: { color:'#21262d' } },
|
||||
y: { ticks: { color:'#484f58', maxTicksLimit:5, callback: v => tickFmt(v) }, grid: { color:'#21262d' }, title: { display:true, text:yUnit, color:'#484f58', font:{size:10} } },
|
||||
y: { ticks: { color:'#484f58', maxTicksLimit:5, callback: v => tickFmt(v) }, grid: { color:'#21262d' }, title: { display:true, text:unit, color:'#484f58', font:{size:10} } },
|
||||
},
|
||||
},
|
||||
plugins: [{
|
||||
id: 'triggerLine',
|
||||
id: 'triggerAndPeakMarkers',
|
||||
afterDraw(chart) {
|
||||
const zeroIdx = rTimes.findIndex(t => parseFloat(t) >= 0);
|
||||
if (zeroIdx < 0) return;
|
||||
const { ctx, scales: {x, y} } = chart;
|
||||
const px = x.getPixelForValue(zeroIdx);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
|
||||
ctx.strokeStyle = 'rgba(248,81,73,0.7)'; ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
|
||||
// Trigger line at t = trigger_ms (typically 0).
|
||||
const triggerMs = data.trigger_ms ?? 0;
|
||||
const zeroIdx = rTimes.findIndex(s => parseFloat(s) >= triggerMs);
|
||||
if (zeroIdx >= 0) {
|
||||
const px = x.getPixelForValue(zeroIdx);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px, y.top); ctx.lineTo(px, y.bottom);
|
||||
ctx.strokeStyle = 'rgba(248,81,73,0.7)'; ctx.lineWidth = 1.5;
|
||||
ctx.setLineDash([4, 3]); ctx.stroke(); ctx.restore();
|
||||
}
|
||||
// Peak marker (dot at the channel's peak sample).
|
||||
if (peakPlotIdx >= 0 && peakPlotIdx < rData.length) {
|
||||
const px = x.getPixelForValue(peakPlotIdx);
|
||||
const py = y.getPixelForValue(rData[peakPlotIdx]);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, 3.2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.strokeStyle = '#0d1117';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
},
|
||||
}],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// One-time normaliser for the legacy /device/event/{idx}/waveform shape
|
||||
// (samples as int16 ADC counts in `channels.{ch}: [...]`). Bridges the
|
||||
// gap if a stale cache or non-upgraded server returns the old format.
|
||||
function _legacyWaveformToPlotV1(data) {
|
||||
const sr = data.sample_rate || 1024;
|
||||
const pretrig = data.pretrig_samples || 0;
|
||||
const decoded = data.samples_decoded || 0;
|
||||
const total = data.total_samples || decoded;
|
||||
const dt = 1000 / sr;
|
||||
const t0 = -pretrig * dt;
|
||||
|
||||
// Apply the CORRECT scale: 10 in/s full-scale for Normal range.
|
||||
const geoFs = 10.0;
|
||||
const geoScale = geoFs / 32768;
|
||||
const ch = data.channels || {};
|
||||
const micPeak = data.peak_values?.micl_psi ?? null;
|
||||
const micPeakCounts = (ch.MicL || ch.Mic || []).reduce((m, v) => Math.max(m, Math.abs(v)), 0);
|
||||
const micScale = (micPeak != null && micPeakCounts > 0) ? micPeak / micPeakCounts : 1.0;
|
||||
|
||||
const mkGeo = (counts) => {
|
||||
if (!counts || !counts.length) return [];
|
||||
return counts.map(c => c * geoScale);
|
||||
};
|
||||
const mkMic = (counts) => {
|
||||
if (!counts || !counts.length) return [];
|
||||
return counts.map(c => c * micScale);
|
||||
};
|
||||
|
||||
return {
|
||||
schema: 'sfm.plot.v1',
|
||||
event_id: data.event_id || null,
|
||||
serial: data.serial || '',
|
||||
timestamp: data.timestamp?.display || data.timestamp || '',
|
||||
record_type: data.record_type,
|
||||
waveform_key: null,
|
||||
time_axis: {
|
||||
sample_rate: sr, pretrig_samples: pretrig, total_samples: total,
|
||||
n_samples: decoded, t0_ms: t0, dt_ms: dt,
|
||||
rectime_seconds: data.rectime_seconds || 0,
|
||||
},
|
||||
geo_range: 'normal', geo_full_scale_ips: geoFs, trigger_ms: 0,
|
||||
channels: {
|
||||
Tran: { unit:'in/s', values: mkGeo(ch.Tran), peak: data.peak_values?.tran_in_s ?? null, peak_t_ms: null },
|
||||
Vert: { unit:'in/s', values: mkGeo(ch.Vert), peak: data.peak_values?.vert_in_s ?? null, peak_t_ms: null },
|
||||
Long: { unit:'in/s', values: mkGeo(ch.Long), peak: data.peak_values?.long_in_s ?? null, peak_t_ms: null },
|
||||
MicL: { unit:'psi', values: mkMic(ch.MicL || ch.Mic), peak: micPeak, peak_t_ms: null },
|
||||
},
|
||||
peak_values: data.peak_values || {},
|
||||
};
|
||||
}
|
||||
|
||||
// ── DB tabs ────────────────────────────────────────────────────────────────────
|
||||
let histLoaded = false;
|
||||
let unitsLoaded = false;
|
||||
@@ -2082,7 +2376,9 @@ async function loadHistory() {
|
||||
for (const ev of events) {
|
||||
const tr = document.createElement('tr');
|
||||
const pvs = ev.peak_vector_sum;
|
||||
const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0);
|
||||
tr.classList.add('clickable');
|
||||
tr.title = 'Click to review (open sidecar editor)';
|
||||
tr.dataset.eventId = ev.id;
|
||||
tr.innerHTML = `
|
||||
<td>${_fmtTs(ev.timestamp)}</td>
|
||||
<td class="td-key">${ev.serial ?? '—'}</td>
|
||||
@@ -2095,24 +2391,157 @@ async function loadHistory() {
|
||||
<td class="td-text">${ev.client ?? '—'}</td>
|
||||
<td class="td-dim">${ev.record_type ?? '—'}</td>
|
||||
<td class="td-dim" style="font-size:10px">${ev.waveform_key ?? '—'}</td>
|
||||
<td>${ev.false_trigger ? '<span class="ft-badge">FALSE</span>' : `<button class="ft-toggle-btn" onclick="toggleFalseTrigger(${ev.id}, this)" title="Flag as false trigger">Flag</button>`}</td>
|
||||
<td>${ev.false_trigger ? '<span class="ft-badge">FALSE</span>' : ''}</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => openSidecarModal(ev.id));
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFalseTrigger(id, btn) {
|
||||
btn.disabled = true;
|
||||
// ── Sidecar review modal ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Opens on row click in the History table. Loads the .sfm.json sidecar
|
||||
// for the event via GET /db/events/{id}/sidecar, lets the user toggle
|
||||
// false_trigger / edit notes / set reviewer, and saves via PATCH on the
|
||||
// same URL. This mirrors the workflow used by the monthly vibration
|
||||
// summary process — most of the rich review UX lives in Terra-View;
|
||||
// this is the SFM-standalone equivalent for testing / direct edits.
|
||||
|
||||
let _scCurrentEventId = null;
|
||||
let _scCurrentSidecar = null;
|
||||
|
||||
async function openSidecarModal(eventId) {
|
||||
_scCurrentEventId = eventId;
|
||||
_scCurrentSidecar = null;
|
||||
document.getElementById('sc-status').textContent = 'Loading sidecar…';
|
||||
document.getElementById('sc-status').className = 'sc-status';
|
||||
document.getElementById('sc-overlay').classList.add('visible');
|
||||
// Reset edit fields
|
||||
document.getElementById('sc-edit-ft').checked = false;
|
||||
document.getElementById('sc-edit-reviewer').value = '';
|
||||
document.getElementById('sc-edit-notes').value = '';
|
||||
|
||||
try {
|
||||
const r = await fetch(`${api()}/db/events/${id}/false_trigger?value=true`, { method: 'PATCH' });
|
||||
if (!r.ok) throw new Error(r.statusText);
|
||||
btn.outerHTML = '<span class="ft-badge">FALSE</span>';
|
||||
const r = await fetch(`${api()}/db/events/${eventId}/sidecar`);
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.detail || r.statusText);
|
||||
}
|
||||
const data = await r.json();
|
||||
_scCurrentSidecar = data;
|
||||
_renderSidecar(data);
|
||||
document.getElementById('sc-status').textContent = '';
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
alert(`Failed to flag: ${e.message}`);
|
||||
document.getElementById('sc-status').className = 'sc-status error';
|
||||
document.getElementById('sc-status').textContent = `Load failed: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function _renderSidecar(data) {
|
||||
const ev = data.event || {};
|
||||
const pv = data.peak_values || {};
|
||||
const pi = data.project_info || {};
|
||||
const bw = data.blastware || {};
|
||||
const src = data.source || {};
|
||||
const rev = data.review || {};
|
||||
|
||||
document.getElementById('sc-title').textContent = `Event — ${bw.filename || ev.waveform_key || 'unknown'}`;
|
||||
|
||||
const fmtPpv = v => (v == null ? '—' : Number(v).toFixed(5) + ' in/s');
|
||||
const fmtMic = v => {
|
||||
if (v == null || v <= 0) return '—';
|
||||
const dbl = 20 * Math.log10(v / DBL_REF);
|
||||
return `${dbl.toFixed(1)} dBL (${v.toExponential(2)} psi)`;
|
||||
};
|
||||
|
||||
document.getElementById('sc-f-serial').textContent = ev.serial || '—';
|
||||
document.getElementById('sc-f-ts').textContent = ev.timestamp || '—';
|
||||
document.getElementById('sc-f-rt').textContent = ev.record_type || '—';
|
||||
document.getElementById('sc-f-sr').textContent = (ev.sample_rate ?? '—') + (ev.sample_rate ? ' sps' : '');
|
||||
document.getElementById('sc-f-key').textContent = ev.waveform_key || '—';
|
||||
|
||||
document.getElementById('sc-f-tran').textContent = fmtPpv(pv.transverse);
|
||||
document.getElementById('sc-f-vert').textContent = fmtPpv(pv.vertical);
|
||||
document.getElementById('sc-f-long').textContent = fmtPpv(pv.longitudinal);
|
||||
document.getElementById('sc-f-pvs').textContent = fmtPpv(pv.vector_sum);
|
||||
document.getElementById('sc-f-mic').textContent = fmtMic(pv.mic_psi);
|
||||
|
||||
document.getElementById('sc-f-project').textContent = pi.project || '—';
|
||||
document.getElementById('sc-f-client').textContent = pi.client || '—';
|
||||
document.getElementById('sc-f-operator').textContent = pi.operator || '—';
|
||||
document.getElementById('sc-f-loc').textContent = pi.sensor_location || '—';
|
||||
|
||||
document.getElementById('sc-f-bw').textContent = bw.filename || '—';
|
||||
document.getElementById('sc-f-bwsize').textContent = bw.filesize != null ? `${bw.filesize} bytes` : '—';
|
||||
document.getElementById('sc-f-sha').textContent = bw.sha256 || '—';
|
||||
document.getElementById('sc-f-src').textContent = src.kind || '—';
|
||||
document.getElementById('sc-f-cap').textContent = src.captured_at || '—';
|
||||
|
||||
document.getElementById('sc-edit-ft').checked = !!rev.false_trigger;
|
||||
document.getElementById('sc-edit-reviewer').value = rev.reviewer || '';
|
||||
document.getElementById('sc-edit-notes').value = rev.notes || '';
|
||||
|
||||
document.getElementById('sc-raw-json').textContent = JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
function closeSidecarModal() {
|
||||
document.getElementById('sc-overlay').classList.remove('visible');
|
||||
_scCurrentEventId = null;
|
||||
_scCurrentSidecar = null;
|
||||
}
|
||||
|
||||
function onSidecarOverlayClick(e) {
|
||||
// Click on the dimmed backdrop (but NOT on the modal itself) closes.
|
||||
if (e.target.id === 'sc-overlay') closeSidecarModal();
|
||||
}
|
||||
|
||||
async function saveSidecarReview() {
|
||||
if (!_scCurrentEventId) return;
|
||||
const btn = document.getElementById('sc-save-btn');
|
||||
const status = document.getElementById('sc-status');
|
||||
btn.disabled = true;
|
||||
status.className = 'sc-status';
|
||||
status.textContent = 'Saving…';
|
||||
|
||||
const review = {
|
||||
false_trigger: document.getElementById('sc-edit-ft').checked,
|
||||
reviewer: document.getElementById('sc-edit-reviewer').value.trim() || null,
|
||||
notes: document.getElementById('sc-edit-notes').value,
|
||||
};
|
||||
|
||||
try {
|
||||
const r = await fetch(`${api()}/db/events/${_scCurrentEventId}/sidecar`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ review }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const e = await r.json().catch(() => ({}));
|
||||
throw new Error(e.detail || r.statusText);
|
||||
}
|
||||
const updated = await r.json();
|
||||
_scCurrentSidecar = updated;
|
||||
_renderSidecar(updated);
|
||||
status.className = 'sc-status ok';
|
||||
status.textContent = 'Saved.';
|
||||
// Refresh the History table so the false_trigger badge reflects the change.
|
||||
if (typeof loadHistory === 'function') loadHistory();
|
||||
setTimeout(closeSidecarModal, 600);
|
||||
} catch (e) {
|
||||
status.className = 'sc-status error';
|
||||
status.textContent = `Save failed: ${e.message}`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Esc closes the modal.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && document.getElementById('sc-overlay').classList.contains('visible')) {
|
||||
closeSidecarModal();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Units tab ──────────────────────────────────────────────────────────────────
|
||||
async function loadUnits() {
|
||||
unitsLoaded = true;
|
||||
@@ -2274,5 +2703,81 @@ document.getElementById('api-base').value = window.location.origin;
|
||||
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); });
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ════════════════════════════════════════════════════════════════
|
||||
Sidecar review modal (Database events table → row click)
|
||||
═══════════════════════════════════════════════════════════════════ -->
|
||||
<div class="sc-overlay" id="sc-overlay" onclick="onSidecarOverlayClick(event)">
|
||||
<div class="sc-modal" id="sc-modal">
|
||||
<div class="sc-header">
|
||||
<h3 id="sc-title">Event</h3>
|
||||
<button class="sc-close" onclick="closeSidecarModal()">×</button>
|
||||
</div>
|
||||
<div class="sc-body">
|
||||
<div class="sc-section">
|
||||
<h4>Event</h4>
|
||||
<dl class="sc-grid">
|
||||
<dt>Serial</dt> <dd id="sc-f-serial">—</dd>
|
||||
<dt>Timestamp</dt> <dd id="sc-f-ts">—</dd>
|
||||
<dt>Record type</dt> <dd id="sc-f-rt">—</dd>
|
||||
<dt>Sample rate</dt> <dd id="sc-f-sr">—</dd>
|
||||
<dt>Waveform key</dt> <dd id="sc-f-key">—</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="sc-section">
|
||||
<h4>Peaks</h4>
|
||||
<dl class="sc-grid">
|
||||
<dt>Tran</dt> <dd id="sc-f-tran">—</dd>
|
||||
<dt>Vert</dt> <dd id="sc-f-vert">—</dd>
|
||||
<dt>Long</dt> <dd id="sc-f-long">—</dd>
|
||||
<dt>PVS</dt> <dd id="sc-f-pvs">—</dd>
|
||||
<dt>Mic</dt> <dd id="sc-f-mic">—</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="sc-section">
|
||||
<h4>Project</h4>
|
||||
<dl class="sc-grid">
|
||||
<dt>Project</dt> <dd id="sc-f-project">—</dd>
|
||||
<dt>Client</dt> <dd id="sc-f-client">—</dd>
|
||||
<dt>Operator</dt> <dd id="sc-f-operator">—</dd>
|
||||
<dt>Location</dt> <dd id="sc-f-loc">—</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="sc-section">
|
||||
<h4>Source / files</h4>
|
||||
<dl class="sc-grid">
|
||||
<dt>BW filename</dt> <dd id="sc-f-bw">—</dd>
|
||||
<dt>BW filesize</dt> <dd id="sc-f-bwsize">—</dd>
|
||||
<dt>BW sha256</dt> <dd id="sc-f-sha">—</dd>
|
||||
<dt>Source kind</dt> <dd id="sc-f-src">—</dd>
|
||||
<dt>Captured at</dt> <dd id="sc-f-cap">—</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="sc-section">
|
||||
<h4>Review (editable)</h4>
|
||||
<div class="sc-row">
|
||||
<input type="checkbox" id="sc-edit-ft" />
|
||||
<label for="sc-edit-ft">False trigger</label>
|
||||
</div>
|
||||
<div class="sc-row">
|
||||
<label for="sc-edit-reviewer" style="min-width:60px">Reviewer</label>
|
||||
<input type="text" id="sc-edit-reviewer" placeholder="e.g. brian" />
|
||||
</div>
|
||||
<label for="sc-edit-notes" style="font-size:11px;color:var(--text-mute)">Notes</label>
|
||||
<textarea id="sc-edit-notes" placeholder="e.g. truck thump near sensor 14:23 — false trigger"></textarea>
|
||||
</div>
|
||||
<details class="sc-raw">
|
||||
<summary>Raw sidecar JSON (read-only peek)</summary>
|
||||
<pre id="sc-raw-json"></pre>
|
||||
</details>
|
||||
</div>
|
||||
<div class="sc-footer">
|
||||
<span class="sc-status" id="sc-status"></span>
|
||||
<button class="btn btn-ghost" onclick="closeSidecarModal()">Cancel</button>
|
||||
<button class="btn" id="sc-save-btn" onclick="saveSidecarReview()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user