diff --git a/tests/test_web_checks.py b/tests/test_web_checks.py index 97d2cfe..4ee3c7d 100644 --- a/tests/test_web_checks.py +++ b/tests/test_web_checks.py @@ -76,7 +76,9 @@ def test_failed_shot_switch_reverts_the_select(): def test_fire_and_forget_panels_render_their_errors(): """JS-07: the /db and /surrogate fetches carry the figures.js one-line .catch.""" replay = _served("replay.js") - assert ".catch(() => { $('tab-compare').innerHTML" in replay + # /db failure: only the catalog-clouds panel blanks — the comparison bar and the search live in the + # same Compare workspace and must survive a /db outage. + assert ".catch(() => { const el = $('rp-clouds'); if (el) el.innerHTML" in replay assert ".catch(() => { const el = $('rp-sur'); if (el) el.innerHTML" in replay diff --git a/web/lessons.json b/web/lessons.json index 514a6f4..33afc8a 100644 --- a/web/lessons.json +++ b/web/lessons.json @@ -204,7 +204,7 @@ { "id": "7.1", "ch": 7, "title": "A law is a fit", "tab": "replay", "shot": 30166, "slice": { "t": 0.27 }, "focus": ["strip-w"], - "body": "The warm curves come from [[scaling laws|scaling-law]]: power-law fits of confinement time across thousands of shots on many machines. They are how ITER was sized. But the machines in that database were conventional, doughnut-shaped tokamaks. MAST is a cored apple, and published studies found it scales differently, with a weaker dependence on current and a much stronger one on field. So on MAST the law is a reference line, not the truth.", + "body": "The warm curves come from [[scaling laws|scaling-law]]: power-law fits of confinement time across thousands of shots on many machines. They are how ITER was sized. But the machines in those fits were conventional, doughnut-shaped tokamaks. MAST is a cored apple, and published studies found it scales differently, with a weaker dependence on current and a much stronger one on field. So on MAST the law is a reference line, not the truth.", "deeper": "Valovič et al., Nucl. Fusion 49 (2009) 075016 fitted MAST H-modes as W ∝ Ip0.59 BT1.4, against [[IPB98(y,2)|ipb98]]'s Ip0.93 BT0.15. The same paper notes the magnitudes broadly agree." }, { "id": "7.2", "ch": 7, "title": "One shot among thousands", diff --git a/web/replay.js b/web/replay.js index b0fcae7..4543a19 100644 --- a/web/replay.js +++ b/web/replay.js @@ -37,7 +37,7 @@ - +
Nothing side by side yet. Tick 2–6 shots in the catalog below and they load here — + or press Compare… in the Replay room to bring the shot you are watching as the reference.
+The shot catalog needs the FusionLab server (/db). Reload to retry.
'; }); + .catch(() => { const el = $('rp-clouds'); if (el) el.innerHTML = 'The shot catalog needs the FusionLab server (/db). Reload to retry.
'; }); // Snapshot the shared link BEFORE initDbSearch(): its searchDb() rewrite canonicalizes ?compare= into // numeric order while no live compare exists yet, which silently re-points the reference — the first id // of a shared link is the reference and must survive the boot. @@ -371,10 +389,15 @@ // An uncached ?shot= id is replayable too — the dropdown list only knows the shipped cache. const want = list.shots.find(s => s.shot_id === asked) || (asked ? { shot_id: asked } : null) || list.shots.find(s => s.shot_id === 30166) || list.shots[0]; - if (cmpIds.length >= 2) startCompare(cmpIds); // compare drives the reference render itself - else { - if (want) { $('rp-shot').value = want.shot_id; await loadShot(want.shot_id); } - if (cmpIds.length === 1) startCompare(cmpIds); // a one-id link: the prompt shows beside the shot + if (cmpIds.length) { + // A legacy ?compare= link canonicalizes into the hash grammar and mounts through the router: the + // comparison reads in the Compare workspace now, and the route owns Back/Forward and Share. + const qs = new URLSearchParams(location.search); qs.delete('shot'); qs.delete('compare'); + const rest = qs.toString(); + history.replaceState(null, '', location.pathname + (rest ? '?' + rest : '') + '#/compare/' + [...new Set(cmpIds)].join(',')); + window.FusionLabRouter.route(); + } else if (want) { + $('rp-shot').value = want.shot_id; await loadShot(want.shot_id); } // deep link to a what-if: /?shot=30192&whatif=1&P_nbi=1.3&nbi_shift_s=-0.03 (edits the gate refuses are ignored) if (q.get('whatif')) { @@ -400,7 +423,7 @@ on = !!on && !!shot; // nothing to play until a shot has loaded $('rp-play').textContent = on ? '❚❚ PAUSE' : '▶ PLAY'; $('rp-play').classList.toggle('on', !!on); - if (!on && was && cmp.on) cmpThomson(); // on pause the compared shots' Thomson profiles catch up with the cursor + if (!on && was && cmp.on) cpThomson(); // on pause the compared shots' Thomson profiles catch up with the cursor if (!on || !shot) return; const slider = $('rp-t'); if (+slider.value >= +slider.max) { slider.value = 0; scrub(0); } @@ -443,8 +466,7 @@ } } - async function loadShot(id) { - if (cmp.on) return cmpSetRef(id); // in compare, every pick — dropdown, search row — re-points the reference + async function loadShot(id) { // the single-shot path: a Replay pick never touches a live compare — the two workspaces are independent const seq = ++loadSeq; watchId = id; // the status chip follows this pick through the machine's states let next; @@ -529,13 +551,11 @@ slider.value = peak; document.dispatchEvent(new CustomEvent('fusionlab:shot', { detail: { shot_id: id, n: t.length, heating: m.heating || '' } })); scrub(peak); - if (cmp.on) { cmpTeSetup(); drawDw(); } // the difference strip and the other shots' Thomson traces Promise.all([getPsi(id, peak), getLines(id, peak)]).then(() => prefetch(id, t.length)); } - // The four time strips. P, Ip and — in single-shot mode — W and the limits are the shot's own measured + - // model traces; in compare mode W and limits become one measured trace per shot (per-shot colour, legend, - // unchanged units) and the reference's model curves step aside while the difference strip carries the rest. + // The Replay room's four time strips: the shot's own measured + model traces. The side-by-side compare + // read lives in the Compare workspace (drawCmpW/drawCmpLim/drawDw) — this room stays single-shot. function drawStrips() { const me = shot.measured, mo = shot.model, t = me.t_s; Plotly.react('rp-p', [ @@ -543,7 +563,6 @@ line(t, me.P_nbi_MW, 'Beams (logged peak, box)', C.magenta, { line: { color: C.magenta, width: 2, shape: 'hv' } }), ], stripLayout(false), CFG); Plotly.react('rp-ip', [line(t, me.Ip_MA, 'Ip', C.blue)], stripLayout(false, { showlegend: false }), CFG); - if (cmp.on) { drawCmpW(); drawCmpLim(); return; } Plotly.react('rp-w', [ line(t, me.W_MJ, 'Measured (EFIT)', C.blue, { line: { color: C.blue, width: 3 } }), line(t, mo.W_L_MJ, 'ITER89-P (L-mode law)', C.orange), @@ -631,7 +650,7 @@ if (db) markDb(i); if (use3) window.Vessel3D.setSlice(i, linesOn ? linesNow.get(shot.meta.shot_id + ':' + i) : null); // boundary follows the slider at once clearTimeout(psiTimer); - psiTimer = setTimeout(() => { drawPsi(i); drawLines(i); cmpThomson(); }, 40); // cmpThomson: the compared shots' profiles follow the cursor + psiTimer = setTimeout(() => { drawPsi(i); drawLines(i); cpThomsonSoon(); }, 40); // cpThomson: the compared shots' profiles follow the cursor document.dispatchEvent(new CustomEvent('fusionlab:slice', { detail: sliceState(i) })); } @@ -887,7 +906,7 @@ if (p.thomson) Plotly.restyle('rp-te', { x: [p.thomson.R], y: [p.thomson.Te_keV] }, [0]); } - // ---- shot database: where this shot sits among the others + // ---- the catalog: where this shot sits among the others function drawDb() { const c = db.columns, nbi = c.P_nbi_MW.map(p => p > 0.3); $('rp-db-n').textContent = db.n.toLocaleString(); @@ -1074,7 +1093,7 @@ return searchDb(); } if (e.target.id === 'rp-srch-next') { srchOffset += SRCH_PAGE; return searchDb(); } - if (e.target.id === 'rp-srch-cmp') { window.FusionLab.showTab('replay'); return startCompare([...srchSel]); } + if (e.target.id === 'rp-srch-cmp') return startCompare([...srchSel]); // already in the Compare workspace const a = e.target.closest('a[data-shot]'); if (a) { e.preventDefault(); openReplay(+a.dataset.shot); } }); @@ -1124,13 +1143,13 @@ srchPaint(); } - function cmpChrome() { // bar, difference strip and the W-strip note swap on entering/leaving compare - const showing = cmp.on && cmp.ids.length >= 2; - $('rp-cmp-bar').hidden = !cmp.on; - $('rp-dw-strip').hidden = !showing; - document.querySelector('#cell-tr .strips').classList.toggle('cmp-on', showing); - $('rp-tr-note').textContent = cmp.on ? 'one measured trace per shot' : 'laws are fed the measured power'; - if (showing) $('rp-dw-label').innerHTML = `ΔW vs #${cmp.ref} MJ · dashed W/Wref · computed`; + // The Compare workspace's chrome follows the compare state: the bar, the read panel (vs its empty-state + // prompt) and the ΔW strip (only when a difference exists to read — two or more shots). + function cpChrome() { + $('cp-bar').hidden = !cmp.on; + $('cp-empty').hidden = cmp.on; + $('cp-strips').hidden = !cmp.on; + $('cp-dw-strip').hidden = cmp.ids.length < 2; } async function startCompare(idsIn) { @@ -1141,10 +1160,17 @@ cmp.ref = cmp.ids[0] ?? null; cmp.on = true; watchId = null; paintLoadChip(); // the compare bar owns per-shot state now; the single chip stands down - cmpChrome(); cmpUrl(); cmpSyncSel(); - if (cmp.ids.length < 2) { cmpPaint(); return; } // Empty: a prompt with a shortcut back to the catalog + cpChrome(); cmpUrl(); cmpSyncSel(); + if (cmp.ids.length < 2) { + cmpPaint(); // Empty: a prompt with a shortcut back to the catalog + // A one-shot compare (the Replay "Compare…" preload, a one-id deep link): the read panel shows + // the reference's own measured curves; the bar prompts for more shots to layer in. + if (shot && shot.meta.shot_id === cmp.ref) { SL.markReady(cmp.ref); drawCmpAll(); scrub(+$('rp-t').value); } + else if (cmp.ref != null) cmpFetch(cmp.ref, p => renderShot(p)); + return; + } // The reference renders through the same path as a single pick; it may already be on screen (?shot= deep link). - if (shot && shot.meta.shot_id === cmp.ref) { SL.markReady(cmp.ref); drawStrips(); scrub(+$('rp-t').value); } + if (shot && shot.meta.shot_id === cmp.ref) { SL.markReady(cmp.ref); drawCmpAll(); scrub(+$('rp-t').value); } for (const id of cmp.ids) { if (SL.get(id) === 'ready') continue; cmpFetch(id, id === cmp.ref ? p => renderShot(p) : null); @@ -1153,9 +1179,9 @@ } // One payload per shot, fetched under the shared machine — supersede and honest errors are its to record. - // On resolve, a non-reference shot redraws the overlays; the reference goes through renderShot like any - // single pick. Fetch and render stay separate try-scopes: a rendering exception must surface as an error - // in the console, never masquerade as a fetch failure on the shot. + // On resolve the side-by-side read repaints around the new shot; the reference ALSO goes through + // renderShot like any single pick. Fetch and render stay separate try-scopes: a rendering exception must + // surface as an error in the console, never masquerade as a fetch failure on the shot. async function cmpFetch(id, onReady) { let payload; try { @@ -1166,23 +1192,20 @@ if (payload === null || !cmp.on) return; // superseded, or compare exited while the fetch was in flight cmp.data.set(id, payload); if (onReady) onReady(payload); - else if (id !== cmp.ref) { drawCmpW(); drawCmpLim(); drawDw(); cmpThomson(); } + drawCmpAll(); cmpPaint(); } - async function cmpSetRef(id) { // chips, dropdown picks and search rows all land here - if (!cmp.ids.includes(id)) { // a pick from outside the set: back to the single-shot view, selection kept - exitCompare(); - return loadShot(id); - } + async function cmpSetRef(id) { // the compare chips land here (a Replay pick never re-points the compare) + if (!cmp.ids.includes(id)) return null; // chips are always in the set; a stray call re-points nothing const my = ++loadSeq; // the same last-click-wins guard the single-shot pick uses - if (id !== cmp.ref) { cmp.ref = id; cmpUrl(); cmpChrome(); } + if (id !== cmp.ref) { cmp.ref = id; cmpUrl(); } if (shot && shot.meta.shot_id === id) { - drawStrips(); drawDw(); scrub(+$('rp-t').value); - cmpPaint(); + drawCmpAll(); scrub(+$('rp-t').value); + cmpPaint(); // repaint NOW: a reference move must not leave a stale failure header over the new state return sliceState(+$('rp-t').value); } - if (SL.get(id) === 'ready') { renderShot(cmpData(id)); return sliceState(+$('rp-t').value); } + if (SL.get(id) === 'ready') { renderShot(cmpData(id)); cmpPaint(); return sliceState(+$('rp-t').value); } // the reference itself still has to arrive (the Failed state retries through here); the machine paints // 'fetching' on entry await cmpFetch(id, p => { if (my === loadSeq) renderShot(p); }); @@ -1195,12 +1218,12 @@ SL.drop(id); // the machine aborts a still-in-flight fetch and forgets the shot's state if (cmp.ref === id) { cmp.ref = (cmp.ids.find(x => SL.get(x) === 'ready') ?? cmp.ids[0]) ?? null; - cmpChrome(); const next = cmpData(cmp.ref); if (next && shot && shot.meta.shot_id !== cmp.ref) renderShot(next); - else if (next) { drawStrips(); drawDw(); scrub(+$('rp-t').value); } + else if (next) { drawCmpAll(); scrub(+$('rp-t').value); } } - cmpChrome(); cmpUrl(); cmpSyncSel(); cmpTeSetup(); drawCmpW(); drawCmpLim(); drawDw(); cmpPaint(); + cpChrome(); cmpUrl(); cmpSyncSel(); cmpPaint(); + drawCmpAll(); } function exitCompare() { @@ -1210,9 +1233,7 @@ cmp.ids.forEach(id => SL.drop(id)); // the machine forgets this batch cmp.on = false; cmp.ids = []; cmp.ref = null; cmp.data.clear(); - cmpUrl(); cmpChrome(); - srchPaint(); // the checkboxes stay; the compare button can re-enter - if (shot) { drawStrips(); drawSection(); scrub(+$('rp-t').value); } + cmpUrl(); cpChrome(); cmpSyncSel(); // the checkboxes stay; the selection can be rebuilt from the catalog } function cmpPaint() { // the compare bar: per-shot chips, the state, and the actions the state allows @@ -1232,24 +1253,43 @@ const errs = failed.map(id => `