/ proof-of-concept / plate-dynamics-lab-2
Sign in
proof-of-concept / plate-dynamics-lab-2
plate-dynamics-lab-2 / apps / lab / src / App.tsx
692 lines · 24.0 KBBlameHistoryRaw
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import {
3 CostModel,
4 LIBRARY_FILES,
5 METHODS,
6 SCENARIO_PRESETS,
7 buildModesBundle,
8 buildPlanBundle,
9 buildRunBundle,
10 buildStrikeBundle,
11 checkModesValue,
12 cloneSpec,
13 defaultSpec,
14 methodById,
15 modesKey,
16 parseMethodPlan,
17 parsePlateResult,
18 validateSpec,
19 withEdits,
20 type BackendInfo,
21 type MethodId,
22 type MethodPlan,
23 type PlainValue,
24 type PlateResult,
25 type ProblemSpec,
26} from "@plate-dyn/core";
27import {
28 CodeView,
29 EquationsView,
30 ParameterPanel,
31 RunStatus,
32 useRunner,
33 useSolve,
34 type ParameterSection,
35 type StagedStatus,
36} from "@plate-dyn/components";
37import { ResultsView } from "./views/ResultsView";
38import { ComparisonView } from "./views/ComparisonView";
39import { clearPersistentState, usePersistentState } from "./usePersistentState";
41type RightTab = "equations" | "source" | "run";
42type MiddleTab = "result" | "compare";
44const RIGHT_TABS: { id: RightTab; label: string }[] = [
45 { id: "equations", label: "Equations" },
46 { id: "source", label: "Source" },
47 { id: "run", label: "Run details" },
48];
50/** What is on the bench: the settings a first run needs. */
51const PROBLEM_SECTIONS: ParameterSection[] = [
52 "plate",
53 "material",
54 "suspension",
55 "hammer",
56 "strike",
57];
58/** Reasonable defaults, rarely the point. */
59const ADVANCED_SECTIONS: ParameterSection[] = ["probe", "mic", "render"];
60const ADVANCED_PREFIXES = ["probe", "mic", "render"];
62const SUMMARIES: Record<string, string> = Object.fromEntries(
63 LIBRARY_FILES.map((f) => [f.name, f.summary]),
64);
65SUMMARIES["main.m"] = "Generated from the controls: the params struct, the modes and the strike";
67/** A kept result and what it came from. */
68export interface KeptResult {
69 result: PlateResult;
70 spec: ProblemSpec;
71 stamp: string;
74const isSpec = (v: unknown): v is ProblemSpec => {
75 const s = v as ProblemSpec | null;
76 return (
77 !!s &&
78 typeof s === "object" &&
79 !!s.plate &&
80 !!s.material &&
81 !!s.hole &&
82 !!s.thread &&
83 !!s.hammer &&
84 !!s.strike &&
85 !!s.probe &&
86 !!s.mic &&
87 !!s.render &&
88 !!s.rr &&
89 !!s.fem &&
90 typeof s.render.frame_span === "number"
91 );
92};
93const isRightTab = (v: unknown): v is RightTab => RIGHT_TABS.some((t) => t.id === v);
94const isMiddleTab = (v: unknown): v is MiddleTab => v === "result" || v === "compare";
95const isMethodId = (v: unknown): v is MethodId => v === "rr" || v === "fem";
96const isBool = (v: unknown): v is boolean => typeof v === "boolean";
97const isEdits = (v: unknown): v is Record<string, string> =>
98 !!v && typeof v === "object" && Object.values(v as object).every((x) => typeof x === "string");
100/** Edits to the library files (not main.m), which change the modes and so their cache key. */
101function libraryEdits(edits: Record<string, string>): Record<string, string> {
102 return Object.fromEntries(Object.entries(edits).filter(([k]) => k !== "main.m"));
105export function App() {
106 // Everything the user chooses is remembered in this browser; "Reset to defaults" forgets it all.
107 const [spec, setSpec] = usePersistentState<ProblemSpec>("spec-v1", defaultSpec, isSpec);
108 const [methodId, setMethodId] = usePersistentState<MethodId>("method", () => "rr", isMethodId);
109 const [edits, setEdits] = usePersistentState<Record<string, string>>(
110 "edits",
111 () => ({}),
112 isEdits,
113 );
114 const [rightTab, setRightTab] = usePersistentState<RightTab>(
115 "rightTab",
116 () => "equations",
117 isRightTab,
118 );
119 const [middleTab, setMiddleTab] = usePersistentState<MiddleTab>(
120 "middleTab",
121 () => "result",
122 isMiddleTab,
123 );
124 const [advancedOpen, setAdvancedOpen] = usePersistentState<boolean>(
125 "advancedOpen",
126 () => false,
127 isBool,
128 );
129 const method = methodById(methodId);
130 const runner = useRunner();
131 const [backend, setBackend] = useState<BackendInfo | null>(null);
132 useEffect(() => {
133 void runner.backend.then(setBackend);
134 }, [runner]);
136 const issues = useMemo(() => validateSpec(spec), [spec]);
137 const hasErrors = issues.some((i) => i.severity === "error");
138 const mainEdited = "main.m" in edits;
140 const scenarioId = useMemo(
141 () => SCENARIO_PRESETS.find((s) => JSON.stringify(s.spec) === JSON.stringify(spec))?.id ?? "",
142 [spec],
143 );
145 // The source on screen is what runs: main.m as one script, or its two stages.
146 const displayBundle = useMemo(() => buildRunBundle(spec, method), [spec, method]);
148 const plan = useSolve(parseMethodPlan);
149 const cost = useRef(new CostModel());
150 useEffect(() => {
151 if (hasErrors) return;
152 const t = window.setTimeout(() => {
153 void plan.run({ bundle: withEdits(buildPlanBundle(spec, method), edits) });
154 }, 300);
155 return () => window.clearTimeout(t);
156 // eslint-disable-next-line react-hooks/exhaustive-deps
157 }, [spec, edits, method, hasErrors]);
158 const methodPlan: MethodPlan | null =
159 plan.last && plan.last.method === method.id ? plan.last : null;
160 const planError = plan.state.status === "error" ? plan.state.run.error : null;
162 // ---- the staged run: modes (kept), then the strike
163 const [status, setStatus] = useState<StagedStatus>({ status: "idle" });
164 const [results, setResults] = useState<Partial<Record<MethodId, KeptResult>>>({});
165 const modesCache = useRef(new Map<string, PlainValue>());
166 const runSeq = useRef(0);
167 const running = status.status === "running";
169 /** Identifies the inputs a result came from, so a result from other settings can say so. */
170 const stamp = useMemo(
171 () => JSON.stringify({ spec, edits, method: method.id }),
172 [spec, edits, method.id],
173 );
174 const currentModesKey = modesKey(spec, method.id) + "|" + JSON.stringify(libraryEdits(edits));
175 const modesKept = !mainEdited && modesCache.current.has(currentModesKey);
177 const runMethod = useCallback(
178 async (m: typeof method, s: ProblemSpec, ed: Record<string, string>, st: string) => {
179 const seq = ++runSeq.current;
180 const output: string[] = [];
181 const startedAt = performance.now();
182 const edited = "main.m" in ed;
183 const say = (text: string) => {
184 output.push(text);
185 if (seq === runSeq.current)
186 setStatus((prev) =>
187 prev.status === "running" ? { ...prev, output: [...output] } : prev,
188 );
189 };
190 const fail = (error: string, backend: BackendInfo, cancelled?: boolean) =>
191 setStatus({
192 status: "error",
193 error,
194 output,
195 wallMs: performance.now() - startedAt,
196 backend,
197 cancelled,
198 });
199 let value: PlainValue;
200 let modesMs: number | null = null;
201 let strikeMs = 0;
202 let backendInfo: BackendInfo;
203 if (edited) {
204 setStatus({ status: "running", stage: "script", startedAt, output: [] });
205 const r = await runner.run({ bundle: withEdits(buildRunBundle(s, m), ed) }, say);
206 if (seq !== runSeq.current) return;
207 if (!r.ok) return fail(r.error, r.backend, r.cancelled);
208 value = r.value;
209 modesMs = r.wallMs;
210 backendInfo = r.backend;
211 } else {
212 const key = modesKey(s, m.id) + "|" + JSON.stringify(libraryEdits(ed));
213 let modes = modesCache.current.get(key);
214 if (!modes) {
215 setStatus({ status: "running", stage: "modes", startedAt, output: [] });
216 const r = await runner.run({ bundle: withEdits(buildModesBundle(s, m), ed) }, say);
217 if (seq !== runSeq.current) return;
218 if (!r.ok) return fail(r.error, r.backend, r.cancelled);
219 try {
220 checkModesValue(r.value);
221 } catch (e) {
222 return fail(
223 `the modes stage returned an unexpected shape: ${(e as Error).message}`,
224 r.backend,
225 );
226 }
227 modes = r.value;
228 // keep a handful of the most recent, so switching between a few plates stays instant
229 if (modesCache.current.size >= 6)
230 modesCache.current.delete(modesCache.current.keys().next().value as string);
231 modesCache.current.set(key, modes);
232 modesMs = r.wallMs;
233 if (methodPlan) cost.current.observe(m, "modes", methodPlan.work, r.wallMs);
234 }
235 setStatus({ status: "running", stage: "strike", startedAt, output: [...output] });
236 const r2 = await runner.run(
237 { bundle: withEdits(buildStrikeBundle(s, m), ed), inputs: { modes } },
238 say,
239 );
240 if (seq !== runSeq.current) return;
241 if (!r2.ok) return fail(r2.error, r2.backend, r2.cancelled);
242 value = r2.value;
243 strikeMs = r2.wallMs;
244 backendInfo = r2.backend;
245 }
246 let result: PlateResult;
247 try {
248 result = parsePlateResult(value);
249 } catch (e) {
250 return fail(
251 `the script ran, but its result has an unexpected shape: ${(e as Error).message}`,
252 backendInfo,
253 );
254 }
255 cost.current.observe(
256 m,
257 "strike",
258 result.info.nsamples * result.info.nmodes,
259 strikeMs || modesMs || 0,
260 );
261 setResults((prev) => ({ ...prev, [m.id]: { result, spec: s, stamp: st } }));
262 setStatus({
263 status: "done",
264 output,
265 wallMs: performance.now() - startedAt,
266 modesMs,
267 strikeMs,
268 backend: backendInfo,
269 });
270 },
271 [runner, methodPlan],
272 );
274 const run = useCallback(async () => {
275 if (running || hasErrors) return;
276 await runMethod(method, cloneSpec(spec), { ...edits }, stamp);
277 }, [running, hasErrors, runMethod, method, spec, edits, stamp]);
279 const runBoth = useCallback(async () => {
280 if (running || hasErrors) return;
281 const s = cloneSpec(spec);
282 const ed = { ...edits };
283 for (const m of METHODS) {
284 const st = JSON.stringify({ spec: s, edits: ed, method: m.id });
285 await runMethod(m, s, ed, st);
286 }
287 setMiddleTab("compare");
288 }, [running, hasErrors, runMethod, spec, edits, setMiddleTab]);
290 const cancel = () => {
291 runSeq.current++;
292 runner.cancel();
293 setStatus({ status: "idle" });
294 };
296 const resetAll = () => {
297 if (!window.confirm("Reset every setting and source edit to the defaults?")) return;
298 clearPersistentState();
299 setSpec(defaultSpec());
300 setMethodId("rr");
301 setEdits({});
302 setRightTab("equations");
303 setMiddleTab("result");
304 setAdvancedOpen(false);
305 setResults({});
306 setStatus({ status: "idle" });
307 modesCache.current.clear();
308 };
310 useEffect(() => {
311 const onKey = (e: KeyboardEvent) => {
312 if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
313 e.preventDefault();
314 void run();
315 }
316 };
317 window.addEventListener("keydown", onKey);
318 return () => window.removeEventListener("keydown", onKey);
319 }, [run]);
321 const current = results[method.id] ?? null;
322 const stale = current !== null && !running && current.stamp !== stamp;
323 const estimate = methodPlan
324 ? {
325 modesMs: modesKept ? null : cost.current.modesMs(method, methodPlan.work),
326 strikeMs: cost.current.strikeMs(
327 method,
328 methodPlan.nsamples,
329 Math.min(methodPlan.modes_cap, methodPlan.modes_expected + 6) + 3,
330 ),
331 }
332 : undefined;
333 const estimateMs = estimate ? (estimate.modesMs ?? 0) + estimate.strikeMs : undefined;
334 const slow = estimateMs !== undefined && estimateMs > 5000;
335 const advancedIssue = issues.some(
336 (i) =>
337 i.severity === "error" &&
338 ADVANCED_PREFIXES.some((p) => i.path === p || i.path.startsWith(p + ".")),
339 );
340 const numericsSection: ParameterSection = method.id === "rr" ? "rr" : "fem";
342 return (
343 <div className="app">
344 <header className="app-header">
345 <div>
346 <h1>Plate Dynamics Lab 2</h1>
347 <div className="sub">
348 A small metal plate on a thread, struck by a hammer: its modes, the contact, the sound,
349 by two independent methods, solved by MATLAB code running in your browser.
350 </div>
351 </div>
352 <div className="method-picker">
353 <span className="picker-label">Scenario</span>
354 <select
355 className="pl-select"
356 style={{ width: "auto" }}
357 value={scenarioId}
358 onChange={(e) => {
359 const s = SCENARIO_PRESETS.find((x) => x.id === e.target.value);
360 if (s) setSpec(cloneSpec(s.spec));
361 }}
362 >
363 <option value="">custom settings</option>
364 {SCENARIO_PRESETS.map((s) => (
365 <option key={s.id} value={s.id}>
366 {s.name}
367 </option>
368 ))}
369 </select>
370 <span className="picker-label">Method</span>
371 <select
372 className="pl-select"
373 style={{ width: "auto" }}
374 value={method.id}
375 onChange={(e) => setMethodId(e.target.value as MethodId)}
376 title={method.summary}
377 >
378 {METHODS.map((m) => (
379 <option key={m.id} value={m.id}>
380 {m.name}
381 </option>
382 ))}
383 </select>
384 </div>
385 </header>
386 <div className="app-body">
387 <aside className="col">
388 <div className="action-bar">
389 {running ? (
390 <button className="pl-btn pl-btn-danger run-btn" onClick={cancel}>
391 Cancel
392 </button>
393 ) : (
394 <button
395 className={`pl-btn pl-btn-primary run-btn${stale ? " run-btn-stale" : ""}`}
396 onClick={() => void run()}
397 disabled={hasErrors}
398 title="Ctrl+Enter or Cmd+Enter"
399 >
400 Run {method.shortName}
401 </button>
402 )}
403 <div className="action-meta">
404 {running && (
405 <span className="pl-badge pl-badge-info">
406 {status.status === "running" && status.stage === "modes"
407 ? "solving the modes"
408 : status.status === "running" && status.stage === "strike"
409 ? "striking"
410 : "running"}
411 </span>
412 )}
413 {!running && status.status === "done" && (
414 <span className="pl-badge pl-badge-ok">finished</span>
415 )}
416 {!running && status.status === "error" && (
417 <span className="pl-badge pl-badge-error">error</span>
418 )}
419 {slow && (
420 <span className="pl-badge pl-badge-warn">
421 about {(estimateMs! / 1000).toFixed(0)} s
422 </span>
423 )}
424 {!slow && estimateMs !== undefined && (
425 <span className="pl-muted">
426 about{" "}
427 {estimateMs < 1000
428 ? `${estimateMs.toFixed(0)} ms`
429 : `${(estimateMs / 1000).toFixed(1)} s`}
430 {modesKept ? ", modes kept" : ""}
431 </span>
432 )}
433 <span style={{ flex: 1 }} />
434 <button
435 className="pl-btn"
436 onClick={() => void runBoth()}
437 disabled={running || hasErrors}
438 title="Run both methods on these settings, then open the comparison"
439 >
440 Run both
441 </button>
442 <button
443 className="pl-btn"
444 onClick={resetAll}
445 title="Forget every setting and source edit saved in this browser"
446 >
447 Reset
448 </button>
449 </div>
450 {hasErrors && !running && (
451 <div className="pl-issue">
452 Some settings are out of range; the groups that need attention are marked below.
453 </div>
454 )}
455 </div>
456 <div className="method-note">
457 <strong>{method.name}</strong>
458 <div className="pl-muted">{method.summary}</div>
459 </div>
460 {scenarioId && (
461 <div className="method-note">
462 <strong>{SCENARIO_PRESETS.find((s) => s.id === scenarioId)?.name}</strong>
463 <div className="pl-muted">
464 {SCENARIO_PRESETS.find((s) => s.id === scenarioId)?.note}
465 </div>
466 </div>
467 )}
468 {mainEdited && (
469 <div className="banner">
470 <span>
471 The generated script was edited, so the controls no longer feed the run, which runs
472 the script as one piece. Keep editing the source, or restore it to reconnect the
473 controls.
474 </span>
475 <button
476 className="pl-btn"
477 onClick={() =>
478 setEdits((e) =>
479 Object.fromEntries(Object.entries(e).filter(([k]) => k !== "main.m")),
480 )
481 }
482 >
483 Restore
484 </button>
485 </div>
486 )}
487 <div className={mainEdited ? "pl-disabled" : undefined}>
488 <div className="panel-head">On the bench</div>
489 <ParameterPanel
490 spec={spec}
491 onChange={setSpec}
492 issues={issues}
493 sections={PROBLEM_SECTIONS}
494 open={["hammer", "strike"]}
495 />
496 {methodPlan && !hasErrors && <PlanSummary plan={methodPlan} />}
497 {planError && (
498 <div className="pl-note">The setup refuses this problem: {planError}</div>
499 )}
500 <div className="panel-head">Numerics</div>
501 <ParameterPanel
502 spec={spec}
503 onChange={setSpec}
504 issues={issues}
505 sections={[numericsSection]}
506 open={[numericsSection]}
507 />
508 {methodPlan && (
509 <div className="pl-small pl-muted plan-line">
510 {methodPlan.size_note}; about {methodPlan.modes_expected} modes below{" "}
511 {(methodPlan.f_max / 1000).toFixed(1)} kHz; {methodPlan.nsamples.toLocaleString()}{" "}
512 samples. {modesKept ? "The modes of this plate are kept from an earlier run." : ""}
513 </div>
514 )}
515 <details
516 className="advanced"
517 open={advancedOpen || advancedIssue}
518 onToggle={(e) => setAdvancedOpen((e.currentTarget as HTMLDetailsElement).open)}
519 >
520 <summary>
521 More settings: probe, microphone, render
522 {advancedIssue && (
523 <span className="pl-badge pl-badge-error" style={{ marginLeft: 6 }}>
524 check
525 </span>
526 )}
527 </summary>
528 <ParameterPanel
529 spec={spec}
530 onChange={setSpec}
531 issues={issues}
532 sections={ADVANCED_SECTIONS}
533 open={ADVANCED_SECTIONS}
534 />
535 </details>
536 </div>
537 <div className="pl-small pl-muted">
538 Settings and source edits are saved in this browser. Ctrl+Enter runs.
539 </div>
540 </aside>
542 <main className="col">
543 <div className="tabs" role="tablist">
544 <button
545 className={middleTab === "result" ? "active" : ""}
546 onClick={() => setMiddleTab("result")}
547 role="tab"
548 aria-selected={middleTab === "result"}
549 >
550 {method.shortName}
551 </button>
552 <button
553 className={middleTab === "compare" ? "active" : ""}
554 onClick={() => setMiddleTab("compare")}
555 role="tab"
556 aria-selected={middleTab === "compare"}
557 >
558 Comparison
559 </button>
560 </div>
561 {middleTab === "result" && (
562 <>
563 {stale && (
564 <div className="banner">
565 <span>
566 The settings changed after this run, so what you see below is from the earlier
567 ones.
568 </span>
569 <button className="pl-btn" onClick={() => void run()}>
570 Run again
571 </button>
572 </div>
573 )}
574 {status.status === "error" && <div className="pl-error">{status.error}</div>}
575 {current && (
576 <ResultsView result={current.result} spec={current.spec} method={method} />
577 )}
578 {!current && <EmptyState status={status.status} method={method.shortName} />}
579 </>
580 )}
581 {middleTab === "compare" && <ComparisonView results={results} current={spec} />}
582 </main>
584 <section className="col-right">
585 <div className="tabs" role="tablist">
586 {RIGHT_TABS.map((t) => (
587 <button
588 key={t.id}
589 className={rightTab === t.id ? "active" : ""}
590 onClick={() => setRightTab(t.id)}
591 role="tab"
592 aria-selected={rightTab === t.id}
593 >
594 {t.label}
595 </button>
596 ))}
597 </div>
598 {rightTab === "equations" && (
599 <div className="tab-body">
600 <div className="pl-small pl-muted tab-intro">
601 What the lab solves and how the {method.shortName} method discretizes it. The code
602 that does it is under Source.
603 </div>
604 {method.equationGroups.map((g) => (
605 <div key={g.title} className="eq-group">
606 <h3>{g.title}</h3>
607 <EquationsView equations={g.equations} />
608 </div>
609 ))}
610 </div>
611 )}
612 {rightTab === "source" && (
613 <CodeView
614 files={displayBundle.files}
615 edits={edits}
616 onEditsChange={setEdits}
617 order={["main.m", ...method.readingOrder]}
618 summaries={SUMMARIES}
619 height="100%"
620 />
621 )}
622 {rightTab === "run" && (
623 <div className="tab-body" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
624 <RunStatus
625 state={status}
626 result={current?.result}
627 method={method}
628 backend={backend}
629 estimate={estimate}
630 onCancel={cancel}
631 />
632 </div>
633 )}
634 </section>
635 </div>
636 </div>
637 );
640/** The estimates the plan found, before any run. */
641function PlanSummary({ plan }: { plan: MethodPlan }) {
642 const lines: string[] = [];
643 lines.push(`mass ${(plan.plate_mass * 1000).toPrecision(3)} g`);
644 lines.push(`pendulum ${plan.pendulum_hz.toFixed(2)} Hz`);
645 lines.push(
646 `Hertz contact ${(plan.hertz_duration * 1e6).toPrecision(3)} µs, ${plan.hertz_peak_force.toPrecision(3)} N peak, ${(plan.hertz_impulse * 1e3).toPrecision(3)} mN s on a rigid target`,
647 );
648 lines.push(`coincidence ${(plan.critical_frequency / 1000).toPrecision(3)} kHz`);
649 return (
650 <div className="pl-small pl-muted plan-line">
651 Statics: {lines.join("; ")}.
652 {plan.notes.map((n) => (
653 <div key={n} className="pl-issue pl-issue-warning">
654 {n}
655 </div>
656 ))}
657 </div>
658 );
661/** What the middle column says before anything has been solved. */
662function EmptyState({ status, method }: { status: string; method: string }) {
663 if (status === "running") return <div className="empty">Solving…</div>;
664 if (status === "error")
665 return (
666 <div className="empty">
667 The run failed. The message is above, and the full log is under Run details.
668 </div>
669 );
670 return (
671 <div className="empty">
672 <h2>Nothing solved yet by {method}</h2>
673 <ol>
674 <li>
675 Pick a scenario and a method at the top, or set the plate, the hammer and the strike on
676 the left.
677 </li>
678 <li>
679 Press <strong>Run</strong>, or Ctrl+Enter. <strong>Run both</strong> solves the other
680 method too and opens the comparison.
681 </li>
682 <li>
683 The plate on its thread, its modes, the contact, the fields, the probe traces, the
684 spectrum and the sound appear here.
685 </li>
686 </ol>
687 <p className="pl-muted">
688 The equations are on the right; Source shows the MATLAB that runs, and you can edit it.
689 </p>
690 </div>
691 );
↑↓ move↵openescclose