80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import {
3 CostModel,
4 FD_METHOD,
5 LIBRARY_FILES,
6 SCENARIO_PRESETS,
7 buildGpuStages,
8 buildPlanBundle,
9 buildRunBundle,
10 cloneSpec,
11 defaultSpec,
12 methodBlockers,
13 parseMethodPlan,
14 parsePlateResult,
15 validateSpec,
16 withEdits,
17 type BackendInfo,
18 type MethodPlan,
19 type ProblemSpec,
20} from "@plate-dyn/core";
21import {
22 CodeView,
23 EquationsView,
24 ParameterPanel,
25 RunStatus,
26 useRunner,
27 useSolve,
28 useWebGpuSupport,
29 type ParameterSection,
30} from "@plate-dyn/components";
31import { ResultsView } from "./views/ResultsView";
32import { clearPersistentState, usePersistentState } from "./usePersistentState";
34type RightTab = "equations" | "source" | "run";
36const RIGHT_TABS: { id: RightTab; label: string }[] = [
37 { id: "equations", label: "Equations" },
38 { id: "source", label: "Source" },
39 { id: "run", label: "Run details" },
40];
42const IDLE = { status: "idle" } as const;
43const method = FD_METHOD;
45/** What is on the bench: the settings a first run needs. */
46const PROBLEM_SECTIONS: ParameterSection[] = ["plate", "material", "mount", "excitation"];
47/** Reasonable defaults, rarely the point. */
48const ADVANCED_SECTIONS: ParameterSection[] = ["gravity", "damping", "probe", "listener", "output"];
49const ADVANCED_PREFIXES = ["gravity", "damping", "probe", "listener", "output"];
50const NUMERICS_SECTIONS: ParameterSection[] = ["fd"];
52const SUMMARIES: Record<string, string> = Object.fromEntries(
53 LIBRARY_FILES.map((f) => [f.name, f.summary]),
54);
55SUMMARIES["main.m"] = "Generated from the controls: the params struct and the solver call";
57const isSpec = (v: unknown): v is ProblemSpec => {
58 const s = v as ProblemSpec | null;
59 return (
60 !!s &&
61 typeof s === "object" &&
62 !!s.plate &&
63 !!s.fd &&
64 !!s.mount?.type &&
65 !!s.gravity &&
66 !!s.listener?.model &&
67 !!s.output
68 );
69};
70const isRightTab = (v: unknown): v is RightTab => RIGHT_TABS.some((t) => t.id === v);
71const isBool = (v: unknown): v is boolean => typeof v === "boolean";
72const isEdits = (v: unknown): v is Record<string, string> =>
73 !!v && typeof v === "object" && Object.values(v as object).every((x) => typeof x === "string");
75export function App() {
76 // Everything the user chooses is remembered in this browser; "Reset to defaults" forgets it all.
77 const [spec, setSpec] = usePersistentState<ProblemSpec>("spec-v1", defaultSpec, isSpec);
78 const [edits, setEdits] = usePersistentState<Record<string, string>>(
79 "edits",
80 () => ({}),
81 isEdits,
82 );
83 const [rightTab, setRightTab] = usePersistentState<RightTab>(
84 "rightTab",
85 () => "equations",
86 isRightTab,
87 );
88 const [advancedOpen, setAdvancedOpen] = usePersistentState<boolean>(
89 "advancedOpen",
90 () => false,
91 isBool,
92 );
93 // The time loop runs as its WebGPU port by default: a run of seconds is hundreds of thousands of steps.
94 const [fdGpu, setFdGpu] = usePersistentState<boolean>("fdGpu", () => true, isBool);
95 const gpuSupport = useWebGpuSupport();
96 const gpuOn = fdGpu && gpuSupport.status === "available";
97 const runner = useRunner();
98 const [backend, setBackend] = useState<BackendInfo | null>(null);
99 useEffect(() => {
100 void runner.backend.then(setBackend);
101 }, [runner]);
103 const issues = useMemo(() => validateSpec(spec), [spec]);
104 const hasErrors = issues.some((i) => i.severity === "error");
105 const blockers = methodBlockers(method, spec);
106 const mainEdited = "main.m" in edits;
108 const scenarioId = useMemo(
109 () => SCENARIO_PRESETS.find((s) => JSON.stringify(s.spec) === JSON.stringify(spec))?.id ?? "",
110 [spec],
111 );
113 // Source bundles: what the code view shows is what runs. With the GPU option on, the script is
114 // written as the three stages the host then runs; an edited main script runs as written, in numbl.
115 const bundles = useMemo(() => {
116 const single = buildRunBundle(spec, method, { gpu: gpuOn });
117 const stages = gpuOn && !mainEdited ? (buildGpuStages(spec, method) ?? undefined) : undefined;
118 return { display: single, run: withEdits(single, edits), gpu: stages };
119 }, [spec, edits, gpuOn, mainEdited]);
121 const solve = useSolve(parsePlateResult);
122 const plan = useSolve(parseMethodPlan);
123 const cost = useRef(new CostModel());
125 // Keep the plan (step, cost, statics, pendulum frequencies) current
126 useEffect(() => {
127 if (hasErrors) return;
128 const t = window.setTimeout(() => {
129 const pb = buildPlanBundle(spec, method);
130 if (pb) void plan.run({ bundle: withEdits(pb, edits) });
131 }, 300);
132 return () => window.clearTimeout(t);
133 // eslint-disable-next-line react-hooks/exhaustive-deps
134 }, [spec, edits, hasErrors]);
135 const methodPlan: MethodPlan | null = plan.last;
136 const planError = plan.state.status === "error" ? plan.state.run.error : null;
138 const running = solve.state.status === "running";
140 /** Identifies the inputs a result came from, so a result from other settings can say so. */
141 const stamp = useMemo(() => JSON.stringify({ spec, edits, gpuOn }), [spec, edits, gpuOn]);
142 const [ranWith, setRanWith] = useState<string | null>(null);
144 const run = useCallback(async () => {
145 if (running || hasErrors || blockers.length) return;
146 solve.reset();
147 setRanWith(stamp);
148 const r = await solve.run({ bundle: bundles.run, gpu: bundles.gpu });
149 if (r?.ok) {
150 const res = parsePlateResult(r.value);
151 cost.current.observe(
152 method,
153 Number(res.info.extra.cell_steps),
154 r.wallMs,
155 r.compute?.loop === "webgpu",
156 );
157 }
158 // eslint-disable-next-line react-hooks/exhaustive-deps
159 }, [running, hasErrors, blockers.length, bundles, solve, stamp]);
161 const cancel = () => solve.cancel();
163 const resetAll = () => {
164 if (!window.confirm("Reset every setting and source edit to the defaults?")) return;
165 clearPersistentState();
166 setSpec(defaultSpec());
167 setEdits({});
168 setRightTab("equations");
169 setAdvancedOpen(false);
170 setFdGpu(true);
171 solve.reset();
172 setRanWith(null);
173 };
175 useEffect(() => {
176 const onKey = (e: KeyboardEvent) => {
177 if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
178 e.preventDefault();
179 void run();
180 }
181 };
182 window.addEventListener("keydown", onKey);
183 return () => window.removeEventListener("keydown", onKey);
184 }, [run]);
186 const currentState = ranWith !== null ? solve.state : IDLE;
187 const currentResult = ranWith !== null ? solve.last : null;
188 const stale = ranWith !== null && !running && ranWith !== stamp && currentResult !== null;
189 const estimateMs = methodPlan
190 ? cost.current.estimateMs(method, methodPlan.cell_steps, gpuOn)
191 : undefined;
192 const slow = estimateMs !== undefined && estimateMs > 5000;
193 const advancedIssue = issues.some(
194 (i) =>
195 i.severity === "error" &&
196 ADVANCED_PREFIXES.some((p) => i.path === p || i.path.startsWith(p + ".")),
197 );
198 const equationGroups = method.equationGroups.filter(
199 (g) => !g.mounts || g.mounts.includes(spec.mount.type),
200 );
202 return (
203 <div className="app">
204 <header className="app-header">
205 <div>
206 <h1>Plate Dynamics Lab</h1>
207 <div className="sub">
208 A metal plate on a thread, on pads, in a vise or in the hand: how it swings, rings and
209 sounds, solved by MATLAB code and a WebGPU time loop in your browser.
210 </div>
211 </div>
212 <div className="method-picker">
213 <span className="picker-label">Scenario</span>
214 <select
215 className="pl-select"
216 style={{ width: "auto" }}
217 value={scenarioId}
218 onChange={(e) => {
219 const s = SCENARIO_PRESETS.find((x) => x.id === e.target.value);
220 if (s) setSpec(cloneSpec(s.spec));
221 }}
222 >
223 <option value="">custom settings</option>
224 {SCENARIO_PRESETS.map((s) => (
225 <option key={s.id} value={s.id}>
226 {s.name}
227 </option>
228 ))}
229 </select>
230 </div>
231 </header>
232 <div className="app-body">
233 <aside className="col">
234 <div className="action-bar">
235 {running ? (
236 <button className="pl-btn pl-btn-danger run-btn" onClick={cancel}>
237 Cancel
238 </button>
239 ) : (
240 <button
241 className={`pl-btn pl-btn-primary run-btn${stale ? " run-btn-stale" : ""}`}
242 onClick={() => void run()}
243 disabled={hasErrors || blockers.length > 0}
244 title="Ctrl+Enter or Cmd+Enter"
245 >
246 Run
247 </button>
248 )}
249 <div className="action-meta">
250 {running && <span className="pl-badge pl-badge-info">running</span>}
251 {!running && currentState.status === "done" && (
252 <span className="pl-badge pl-badge-ok">finished</span>
253 )}
254 {!running && currentState.status === "error" && (
255 <span className="pl-badge pl-badge-error">error</span>
256 )}
257 {slow && (
258 <span className="pl-badge pl-badge-warn">
259 about {(estimateMs! / 1000).toFixed(0)} s
260 </span>
261 )}
262 {!slow && estimateMs !== undefined && (
263 <span className="pl-muted">
264 about{" "}
265 {estimateMs < 1000
266 ? `${estimateMs.toFixed(0)} ms`
267 : `${(estimateMs / 1000).toFixed(1)} s`}
268 </span>
269 )}
270 <span style={{ flex: 1 }} />
271 <button
272 className="pl-btn"
273 onClick={resetAll}
274 title="Forget every setting and source edit saved in this browser"
275 >
276 Reset to defaults
277 </button>
278 </div>
279 {hasErrors && !running && (
280 <div className="pl-issue">
281 Some settings are out of range; the groups that need attention are marked below.
282 </div>
283 )}
284 </div>
285 {scenarioId && (
286 <div className="method-note">
287 <strong>{SCENARIO_PRESETS.find((s) => s.id === scenarioId)?.name}</strong>
288 <div className="pl-muted">
289 {SCENARIO_PRESETS.find((s) => s.id === scenarioId)?.note}
290 </div>
291 </div>
292 )}
293 {mainEdited && (
294 <div className="banner">
295 <span>
296 The generated script was edited, so the controls no longer feed the run. Keep
297 editing the source, or restore it to reconnect the controls.
298 </span>
299 <button
300 className="pl-btn"
301 onClick={() =>
302 setEdits((e) =>
303 Object.fromEntries(Object.entries(e).filter(([k]) => k !== "main.m")),
304 )
305 }
306 >
307 Restore
308 </button>
309 </div>
310 )}
311 {blockers.map((b) => (
312 <div className="pl-note" key={b}>
313 {b}
314 </div>
315 ))}
316 <div className={mainEdited ? "pl-disabled" : undefined}>
317 <div className="panel-head">On the bench</div>
318 <ParameterPanel
319 spec={spec}
320 onChange={setSpec}
321 issues={issues}
322 sections={PROBLEM_SECTIONS}
323 open={["mount", "excitation"]}
324 />
325 {methodPlan && !hasErrors && <PlanSummary plan={methodPlan} spec={spec} />}
326 {planError && (
327 <div className="pl-note">The setup refuses this problem: {planError}</div>
328 )}
329 <div className="panel-head">Grid and time</div>
330 <ParameterPanel
331 spec={spec}
332 onChange={setSpec}
333 issues={issues}
334 fdDtMax={methodPlan?.dt_max}
335 sections={NUMERICS_SECTIONS}
336 open={NUMERICS_SECTIONS}
337 fdGpu={{ enabled: fdGpu, onChange: setFdGpu, support: gpuSupport }}
338 />
339 {methodPlan && (
340 <div className="pl-small pl-muted plan-line">
341 {methodPlan.nsteps.toLocaleString()} steps of {(methodPlan.dt * 1e6).toPrecision(3)}{" "}
342 µs on {methodPlan.nx} × {methodPlan.ny} nodes (
343 {methodPlan.nunknowns.toLocaleString()} unknowns),{" "}
344 {methodPlan.cell_steps.toLocaleString()} cell-steps
345 {gpuOn ? ", time loop on the GPU" : ", in numbl"}.
346 </div>
347 )}
348 <details
349 className="advanced"
350 open={advancedOpen || advancedIssue}
351 onToggle={(e) => setAdvancedOpen((e.currentTarget as HTMLDetailsElement).open)}
352 >
353 <summary>
354 More settings: gravity, damping, probe, listener, output
355 {advancedIssue && (
356 <span className="pl-badge pl-badge-error" style={{ marginLeft: 6 }}>
357 check
358 </span>
359 )}
360 </summary>
361 <ParameterPanel
362 spec={spec}
363 onChange={setSpec}
364 issues={issues}
365 sections={ADVANCED_SECTIONS}
366 open={ADVANCED_SECTIONS}
367 />
368 </details>
369 </div>
370 <div className="pl-small pl-muted">
371 Settings and source edits are saved in this browser. Ctrl+Enter runs.
372 </div>
373 </aside>
375 <main className="col">
376 {stale && (
377 <div className="banner">
378 <span>
379 The settings changed after this run, so what you see below is from the earlier ones.
380 </span>
381 <button className="pl-btn" onClick={() => void run()}>
382 Run again
383 </button>
384 </div>
385 )}
386 {currentState.status === "error" && (
387 <div className="pl-error">{currentState.run.error}</div>
388 )}
389 {currentResult && <ResultsView result={currentResult} spec={spec} method={method} />}
390 {!currentResult && <EmptyState status={currentState.status} />}
391 </main>
393 <section className="col-right">
394 <div className="tabs" role="tablist">
395 {RIGHT_TABS.map((t) => (
396 <button
397 key={t.id}
398 className={rightTab === t.id ? "active" : ""}
399 onClick={() => setRightTab(t.id)}
400 role="tab"
401 aria-selected={rightTab === t.id}
402 >
403 {t.label}
404 </button>
405 ))}
406 </div>
407 {rightTab === "equations" && (
408 <div className="tab-body">
409 <div className="pl-small pl-muted tab-intro">
410 What the lab solves, how the {spec.mount.type} mount enters, and how the scheme
411 discretizes it. The code that does it is under Source.
412 </div>
413 {equationGroups.map((g) => (
414 <div key={g.title} className="eq-group">
415 <h3>{g.title}</h3>
416 <EquationsView equations={g.equations} />
417 </div>
418 ))}
419 </div>
420 )}
421 {rightTab === "source" && (
422 <CodeView
423 files={bundles.display.files}
424 edits={edits}
425 onEditsChange={setEdits}
426 order={["main.m", ...method.readingOrder]}
427 summaries={SUMMARIES}
428 height="100%"
429 />
430 )}
431 {rightTab === "run" && (
432 <div className="tab-body" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
433 <RunStatus
434 state={currentState}
435 result={currentResult}
436 method={method}
437 backend={backend}
438 estimateMs={estimateMs}
439 onCancel={cancel}
440 />
441 </div>
442 )}
443 </section>
444 </div>
445 </div>
446 );
447}
449/** The statics and the rigid-body modes the plan found, before any run. */
450function PlanSummary({ plan, spec }: { plan: MethodPlan; spec: ProblemSpec }) {
451 const m = spec.mount;
452 const lines: string[] = [];
453 lines.push(`mass ${(plan.mass * 1000).toFixed(0)} g`);
454 if (plan.tension.length === 1) lines.push(`thread tension ${plan.tension[0].toFixed(2)} N`);
455 if (plan.tension.length === 2)
456 lines.push(`thread tensions ${plan.tension[0].toFixed(2)} and ${plan.tension[1].toFixed(2)} N`);
457 if (plan.sag > 0) lines.push(`sag under gravity ${(plan.sag * 1000).toPrecision(3)} mm`);
458 if (plan.rigid_hz.length > 0) {
459 const hz = plan.rigid_hz.map(
460 (h, k) =>
461 `${Number.isFinite(h) ? (h < 0.05 ? `a ${(1 / Math.max(h, 1e-9) / 60).toFixed(0)} min period` : `${h.toFixed(2)} Hz`) : "unstable"} (${plan.rigid_mode_names[k] ?? ""})`,
462 );
463 lines.push(`rigid-body modes at ${hz.join(", ")}`);
464 }
465 if (m.type === "thread" || m.type === "threads")
466 lines.push(
467 `hangs turned ${(Math.atan2(plan.gx, -plan.gy) * (180 / Math.PI)).toFixed(1)}° in its plane`,
468 );
469 return (
470 <div className="pl-small pl-muted plan-line">
471 Statics: {lines.join("; ")}.
472 {plan.notes
473 .filter((n) => /unstable|slack|widened|no load|clear of/.test(n))
474 .map((n) => (
475 <div key={n} className="pl-issue pl-issue-warning">
476 {n}
477 </div>
478 ))}
479 </div>
480 );
481}
483/** What the middle column says before anything has been solved. */
484function EmptyState({ status }: { status: string }) {
485 if (status === "running") return <div className="empty">Solving…</div>;
486 if (status === "error")
487 return (
488 <div className="empty">
489 The run failed. The message is above, and the full log is under Run details.
490 </div>
491 );
492 return (
493 <div className="empty">
494 <h2>Nothing solved yet</h2>
495 <ol>
496 <li>
497 Pick a scenario at the top, or set the plate, material, mount and strike on the left.
498 </li>
499 <li>
500 Press <strong>Run</strong>, or Ctrl+Enter.
501 </li>
502 <li>
503 The plate in its mount, its displacement and stress, the swing and the support forces, the
504 probe traces and the sound appear here.
505 </li>
506 </ol>
507 <p className="pl-muted">
508 The equations are on the right; Source shows the MATLAB that runs, and you can edit it.
509 </p>
510 </div>
511 );
512}