Plate mechanics lab: Kirchhoff-Love plates solved by numbl in the browser
Three methods (modal superposition, explicit and implicit finite differences)
over one problem spec, generating the MATLAB source that runs. Includes the
lab app, the MDX tutorials, and the .m library with its tests.
108 changed files+11780−0
.gitignoreadded+4−0View file
@@ -0,0 +1,4 @@
1+node_modules/
2+dist/
3+*.tsbuildinfo
4+.DS_Store
.mochi/workflows/deploy.ymladded+71−0View file
@@ -0,0 +1,71 @@
1+name: Deploy the lab
2+
3+# Only apps/lab is published. apps/tutorials builds in the same workspace and is
4+# left out of the artifact deliberately; it is a separate site if it ever wants one.
5+#
6+# configure-pages runs before the build rather than after it, because the build
7+# needs the base path it reports: a mochi site is served at the root of its own
8+# origin when the repository has a site label, and under /<collection>/<repo>/site/
9+# when it does not, and never at /<repo>/.
10+
11+on:
12+ push:
13+ branches: [main]
14+ workflow_dispatch:
15+
16+permissions:
17+ contents: read
18+ pages: write
19+ id-token: write
20+
21+concurrency:
22+ group: pages
23+ cancel-in-progress: false
24+
25+jobs:
26+ build:
27+ runs-on: ubuntu-latest
28+ steps:
29+ - name: Checkout
30+ uses: actions/checkout@v4
31+
32+ - name: Setup Node
33+ uses: actions/setup-node@v4
34+ with:
35+ node-version: 22
36+
37+ - name: Setup Pages
38+ id: pages
39+ uses: actions/configure-pages@v5
40+
41+ - name: Install
42+ run: npm ci
43+
44+ - name: Typecheck
45+ run: npm run typecheck
46+
47+ # Runs the .m library under numbl in Node: modal against finite differences,
48+ # the CFL bound, the mode frequencies. A regression here should not deploy.
49+ - name: Test
50+ run: npm test
51+
52+ - name: Build the lab
53+ env:
54+ DEPLOY_BASE: ${{ steps.pages.outputs.base_path }}
55+ run: npm run build -w apps/lab
56+
57+ - name: Upload artifact
58+ uses: actions/upload-pages-artifact@v3
59+ with:
60+ path: apps/lab/dist
61+
62+ deploy:
63+ needs: build
64+ runs-on: ubuntu-latest
65+ environment:
66+ name: github-pages
67+ url: ${{ steps.deployment.outputs.page_url }}
68+ steps:
69+ - name: Deploy to Pages
70+ id: deployment
71+ uses: actions/deploy-pages@v4
.prettierrcadded+1−0View file
@@ -0,0 +1 @@
1+{ "printWidth": 100, "singleQuote": false, "trailingComma": "all" }
README.mdadded+60−0View file
@@ -0,0 +1,60 @@
1+# Plate Mechanics Lab
2+
3+An interactive lab for the mechanics of thin metal plates. You specify geometry, material, edge conditions and an excitation; the app solves the damped Kirchhoff-Love equation and shows displacement, surface stress (von Mises and principal) over time, a probe time series and spectrum, and the radiated sound at a listener point, which you can play.
4+
5+The numerics run in [numbl](https://github.com/flatironinstitute/numbl), a MATLAB-syntax environment executing in the browser. The MATLAB source shown on screen is the source that ran: edit any file and rerun. No physics is computed in JavaScript; the host only draws arrays and plays samples.
6+
7+## Layout
8+
9+```
10+packages/core problem spec, .m generation, numbl runner, result typing, field and colormap primitives (no UI)
11+packages/core/src/m the MATLAB library: plate_modal.m, plate_fd.m, plate_stress.m, ...
12+packages/components React components: code view, parameter panel, field view, plots, equations, run status, audio
13+apps/lab the app: one arrangement of the components
14+apps/tutorials MDX documents with embedded interactive views (one tutorial: the CFL limit)
15+```
16+
17+The app and the tutorials are peers over the same core and components.
18+
19+## Methods
20+
21+Three methods solve the same problem specification, and the app can run any two and compare them directly:
22+
23+- **Modal superposition** (`plate_modal.m`): closed-form modes of the simply supported rectangle, each integrated exactly in time for piecewise-linear forcing. No time-step limit; the approximation is the modal truncation.
24+- **Explicit finite differences** (`plate_fd.m`): second-order scheme with the 13-point biharmonic stencil, simply supported or clamped edges. Stable only below the CFL bound from `plate_fd_plan.m`; the slider goes past it on purpose.
25+- **Implicit finite differences** (`plate_implicit.m`): the same stencils assembled as sparse matrices (`plate_matrices.m`), stiffness averaged over three time levels (theta scheme, theta = 1/4 is the trapezoidal rule). One sparse Cholesky factorization, two triangular solves per step, no stability limit; the step is chosen for accuracy. Needs numbl 0.4.21 or later for its sparse direct solvers.
26+
27+A method is a data record (`packages/core/src/methods/*.ts`) plus a `.m` file. Adding one means adding both; nothing else in core changes.
28+
29+## The live app
30+
31+The lab is deployed at <https://plate-mechanics-sim.vault1-sites.magland.org/>. `.mochi/workflows/deploy.yml`
32+builds `apps/lab` on every push to `main` and publishes it; the tutorials are not part of that artifact. The
33+site sits at the root of an origin of its own, so the build's base path comes from `configure-pages` rather
34+than being written into the Vite config.
35+
36+## Running
37+
38+```bash
39+npm install
40+npm run dev # the lab, http://localhost:5173
41+npm run dev:tutorials # the tutorials
42+npm test # vitest: runs the .m files in numbl under Node and checks modal vs FD, CFL, frequencies, editing
43+npm run typecheck
44+npm run build
45+```
46+
47+The `.m` files also run unchanged from the numbl CLI, Octave or MATLAB:
48+
49+```bash
50+npx numbl run --path packages/core/src/m my_script.m
51+```
52+
53+## Honest limits
54+
55+- Kirchhoff-Love thin-plate theory: no shear deformation or rotary inertia, so thick plates and very high modes are wrong.
56+- Edge conditions are idealizations; free edges are not yet supported.
57+- Damping is a two-parameter model, chosen, not measured.
58+- Stress is a second derivative of the displacement and converges more slowly than displacement in both methods; near the strike it depends on the load footprint.
59+- Radiated sound is Rayleigh's integral in an infinite baffle: an estimate of the waveform, not a calibrated level.
60+- Explicit finite differences cost about 0.3 µs per cell-step in the browser, and the stable step scales with dx². The default 41 x 27 grid gives 20 ms in a few seconds; refining to 61 x 41 costs about five times more. The implicit scheme removes the step limit (about 1 ms per step on 61 x 41) at the price of detuning the highest modes; long simulated times still belong to the modal method.
apps/lab/index.htmladded+12−0View file
@@ -0,0 +1,12 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>Plate Mechanics Lab</title>
7+ </head>
8+ <body>
9+ <div id="root"></div>
10+ <script type="module" src="/src/main.tsx"></script>
11+ </body>
12+</html>
apps/lab/package.jsonadded+18−0View file
@@ -0,0 +1,18 @@
1+{
2+ "name": "@plate-lab/lab",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "vite build",
9+ "preview": "vite preview",
10+ "typecheck": "tsc -p tsconfig.json --noEmit"
11+ },
12+ "dependencies": {
13+ "@plate-lab/components": "*",
14+ "@plate-lab/core": "*",
15+ "react": "^19.2.0",
16+ "react-dom": "^19.2.0"
17+ }
18+}
apps/lab/src/App.tsxadded+837−0View file
@@ -0,0 +1,837 @@
1+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2+import {
3+ CostModel,
4+ LIBRARY_FILES,
5+ METHODS,
6+ buildCompareBundle,
7+ buildPlanBundle,
8+ buildRunBundle,
9+ defaultSpec,
10+ getMethod,
11+ methodBlockers,
12+ parseCompareResult,
13+ parseMethodPlan,
14+ parsePlateResult,
15+ validateSpec,
16+ withEdits,
17+ type BackendInfo,
18+ type MethodDefinition,
19+ type PlateResult,
20+ type ProblemSpec,
21+ type SourceBundle,
22+} from "@plate-lab/core";
23+import {
24+ CodeView,
25+ CompareView,
26+ EquationsView,
27+ FieldView,
28+ ParameterPanel,
29+ RunStatus,
30+ useRunner,
31+ useSolve,
32+ type ParameterSection,
33+} from "@plate-lab/components";
34+import { ResultsView } from "./views/ResultsView";
35+import { clearPersistentState, usePersistentState } from "./usePersistentState";
36+
37+/** A method id, or "compare" for two methods side by side */
38+type Mode = string;
39+type RightTab = "equations" | "source" | "run";
40+
41+/** The right column, in the order the tabs appear. Equations first: what is being solved before how. */
42+const RIGHT_TABS: { id: RightTab; label: string }[] = [
43+ { id: "equations", label: "Equations" },
44+ { id: "source", label: "Source" },
45+ { id: "run", label: "Run details" },
46+];
47+
48+const IDLE = { status: "idle" } as const;
49+
50+/** What the plate is and what happens to it: the settings a first run needs. */
51+const PROBLEM_SECTIONS: ParameterSection[] = ["plate", "material", "edges", "excitation"];
52+/** Where things are measured and how the loss model is set: defaults are reasonable, rarely the point. */
53+const ADVANCED_SECTIONS: ParameterSection[] = ["damping", "probe", "listener"];
54+/** Validation paths belonging to the advanced sections, so a hidden error can still be found. */
55+const ADVANCED_PREFIXES = ["damping", "probe", "listener"];
56+/** Sample rate, window and frame count: numerics, but the same ones for every method. */
57+const OUTPUT_SECTIONS: ParameterSection[] = ["output"];
58+
59+const SUMMARIES: Record<string, string> = Object.fromEntries(
60+ LIBRARY_FILES.map((f) => [f.name, f.summary]),
61+);
62+SUMMARIES["main.m"] = "Generated from the controls: the params struct and the solver call";
63+SUMMARIES["main_a.m"] = "Generated: first method of the comparison, matched in window and grid";
64+SUMMARIES["main_b.m"] = "Generated: second method of the comparison";
65+SUMMARIES["compare.m"] = "Compares the two result structs";
66+
67+/**
68+ * In compare mode the two runs must cover the same window and, for a field
69+ * comparison, the same grid. A grid-free method (modal) adopts the other's
70+ * grid; both are cut to the shorter duration.
71+ */
72+function matchedSpecs(
73+ spec: ProblemSpec,
74+ a: MethodDefinition,
75+ b: MethodDefinition,
76+): [ProblemSpec, ProblemSpec] {
77+ const duration = Math.min(a.durationOf(spec), b.durationOf(spec));
78+ const grid = b.gridOf?.(spec) ?? a.gridOf?.(spec) ?? spec.output.grid;
79+ const adapt = (m: MethodDefinition, other: MethodDefinition): ProblemSpec => {
80+ let s: ProblemSpec = {
81+ ...spec,
82+ output: { ...spec.output, duration: m.gridOf ? spec.output.duration : duration, grid },
83+ };
84+ if (m.gridOf && m.durationOf(spec) !== duration) {
85+ // shorten a grid-based method to the common window
86+ s = { ...s, [m.settingsKey]: { ...(s[m.settingsKey] as object), duration } } as ProblemSpec;
87+ }
88+ if (m.gridOf && other.gridOf) {
89+ const g = other.gridOf(spec);
90+ if (m.gridOf(spec)[0] !== g[0] || m.gridOf(spec)[1] !== g[1]) {
91+ // two grid methods: put both on b's grid so frames can be compared
92+ s = {
93+ ...s,
94+ [m.settingsKey]: { ...(s[m.settingsKey] as object), nx: grid[0], ny: grid[1] },
95+ } as ProblemSpec;
96+ }
97+ }
98+ return s;
99+ };
100+ return [adapt(a, b), adapt(b, a)];
101+}
102+
103+function renameEntry(b: SourceBundle, name: string): SourceBundle {
104+ return {
105+ ...b,
106+ entry: name,
107+ files: b.files.map((f) => (f.name === b.entry ? { ...f, name } : f)),
108+ };
109+}
110+
111+const isSpec = (v: unknown): v is ProblemSpec => {
112+ const s = v as ProblemSpec | null;
113+ return (
114+ !!s && typeof s === "object" && !!s.plate && !!s.fd && !!s.listener?.patches && !!s.output?.grid
115+ );
116+};
117+const isMode = (v: unknown): v is Mode =>
118+ typeof v === "string" && (v === "compare" || METHODS.some((m) => m.id === v));
119+const isPair = (v: unknown): v is [string, string] =>
120+ Array.isArray(v) && v.length === 2 && v.every((x) => METHODS.some((m) => m.id === x));
121+/** Older stored specs lack sections added later; fill them from the defaults. */
122+const migrateSpec = (v: unknown): ProblemSpec => ({
123+ ...defaultSpec(),
124+ ...(v as Partial<ProblemSpec>),
125+});
126+const isRightTab = (v: unknown): v is RightTab => RIGHT_TABS.some((t) => t.id === v);
127+const isBool = (v: unknown): v is boolean => typeof v === "boolean";
128+const isEdits = (v: unknown): v is Record<string, string> =>
129+ !!v && typeof v === "object" && Object.values(v as object).every((x) => typeof x === "string");
130+
131+export function App() {
132+ // Everything the user chooses is remembered in this browser; "Reset to defaults" forgets it all.
133+ const [storedSpec, setSpec] = usePersistentState<ProblemSpec>("spec-v2", defaultSpec, isSpec);
134+ const spec = useMemo(() => migrateSpec(storedSpec), [storedSpec]);
135+ const [mode, setMode] = usePersistentState<Mode>("mode", () => "modal", isMode);
136+ const [pair, setPair] = usePersistentState<[string, string]>(
137+ "comparePair",
138+ () => ["modal", "fd"],
139+ isPair,
140+ );
141+ const [edits, setEdits] = usePersistentState<Record<string, string>>(
142+ "edits",
143+ () => ({}),
144+ isEdits,
145+ );
146+ // v3: the default view became the equations, so the old stored preference is dropped on purpose.
147+ const [rightTab, setRightTab] = usePersistentState<RightTab>(
148+ "rightTab-v3",
149+ () => "equations",
150+ isRightTab,
151+ );
152+ const [advancedOpen, setAdvancedOpen] = usePersistentState<boolean>(
153+ "advancedOpen",
154+ () => false,
155+ isBool,
156+ );
157+ const runner = useRunner();
158+ const [backend, setBackend] = useState<BackendInfo | null>(null);
159+ useEffect(() => {
160+ void runner.backend.then(setBackend);
161+ }, [runner]);
162+
163+ const issues = useMemo(() => validateSpec(spec), [spec]);
164+ const hasErrors = issues.some((i) => i.severity === "error");
165+ const method = mode === "compare" ? null : getMethod(mode);
166+ const methodA = getMethod(pair[0]);
167+ const methodB = getMethod(pair[1]);
168+ const blockers = method
169+ ? methodBlockers(method, spec)
170+ : methodBlockers(methodA, spec).concat(methodBlockers(methodB, spec));
171+ /** The grid-based method whose plan drives the estimate and the CFL slider */
172+ const planMethod: MethodDefinition | null = method
173+ ? method.planFunction
174+ ? method
175+ : null
176+ : methodB.planFunction
177+ ? methodB
178+ : methodA.planFunction
179+ ? methodA
180+ : null;
181+
182+ // Only the settings the methods in play actually read are shown; the rest would be dead controls.
183+ const methodSections = useMemo<ParameterSection[]>(
184+ () => [...new Set(method ? [method.settingsKey] : [methodA.settingsKey, methodB.settingsKey])],
185+ [method, methodA, methodB],
186+ );
187+
188+ // Source bundles: what the code view shows is what runs
189+ const bundles = useMemo(() => {
190+ if (mode === "compare") {
191+ const [specA, specB] = matchedSpecs(spec, methodA, methodB);
192+ const a = renameEntry(buildRunBundle(specA, methodA), "main_a.m");
193+ const b = renameEntry(buildRunBundle(specB, methodB), "main_b.m");
194+ const c = buildCompareBundle();
195+ const display: SourceBundle = {
196+ files: [a.files[0], b.files[0], c.files[0], ...LIBRARY_FILES],
197+ entry: "compare.m",
198+ resultVariable: "cmp",
199+ };
200+ return {
201+ display,
202+ runA: withEdits(a, edits),
203+ runB: withEdits(b, edits),
204+ runC: withEdits(c, edits),
205+ };
206+ }
207+ const single = buildRunBundle(spec, method!);
208+ return { display: single, run: withEdits(single, edits) };
209+ }, [mode, spec, edits, method, methodA, methodB]);
210+
211+ const mainEdited = ["main.m", "main_a.m", "main_b.m"].some((n) => n in edits);
212+
213+ // Two result slots: the single method uses A; compare uses A and B
214+ const solveA = useSolve(parsePlateResult);
215+ const solveB = useSolve(parsePlateResult);
216+ const solveCmp = useSolve(parseCompareResult);
217+ const plan = useSolve(parseMethodPlan);
218+ const cost = useRef(new CostModel());
219+
220+ // Keep the plan (step, cost, stability bound) current for the grid-based method in play
221+ useEffect(() => {
222+ if (!planMethod) return;
223+ const t = window.setTimeout(() => {
224+ const pb = buildPlanBundle(spec, planMethod);
225+ if (pb) void plan.run({ bundle: withEdits(pb, edits) });
226+ }, 250);
227+ return () => window.clearTimeout(t);
228+ // eslint-disable-next-line react-hooks/exhaustive-deps
229+ }, [spec, planMethod, edits]);
230+ const methodPlan = planMethod ? plan.last : null;
231+
232+ const running =
233+ solveA.state.status === "running" ||
234+ solveB.state.status === "running" ||
235+ solveCmp.state.status === "running";
236+
237+ const observe = (
238+ m: MethodDefinition,
239+ r: { ok: boolean; wallMs: number; value?: unknown } | undefined,
240+ ) => {
241+ if (!r?.ok || !m.planFunction) return;
242+ const res = parsePlateResult(r.value as never);
243+ cost.current.observe(m, Number(res.info.extra.cell_steps), r.wallMs);
244+ };
245+
246+ /** Identifies the inputs a result came from, so a result from other settings can say so. */
247+ const stamp = useMemo(
248+ () => JSON.stringify({ spec, edits, mode, pair }),
249+ [spec, edits, mode, pair],
250+ );
251+ const [ranWith, setRanWith] = useState<{ mode: Mode; stamp: string } | null>(null);
252+
253+ const run = useCallback(async () => {
254+ if (running || hasErrors || blockers.length) return;
255+ // A run starts from a clean slate: the previous frames, plots and sound go away
256+ // immediately, so nothing on screen is ever left over from different settings.
257+ solveA.reset();
258+ solveB.reset();
259+ solveCmp.reset();
260+ setRanWith({ mode, stamp });
261+ if (method) {
262+ const r = await solveA.run({ bundle: bundles.run! });
263+ observe(method, r);
264+ } else {
265+ const a = await solveA.run({ bundle: bundles.runA! });
266+ if (!a?.ok) return;
267+ observe(methodA, a);
268+ const b = await solveB.run({ bundle: bundles.runB! });
269+ if (!b?.ok) return;
270+ observe(methodB, b);
271+ await solveCmp.run({ bundle: bundles.runC!, inputs: { A: a.value, B: b.value } });
272+ }
273+ // eslint-disable-next-line react-hooks/exhaustive-deps
274+ }, [
275+ running,
276+ hasErrors,
277+ blockers.length,
278+ method,
279+ methodA,
280+ methodB,
281+ bundles,
282+ solveA,
283+ solveB,
284+ solveCmp,
285+ mode,
286+ stamp,
287+ ]);
288+
289+ const cancel = () => {
290+ solveA.cancel();
291+ solveB.cancel();
292+ solveCmp.cancel();
293+ };
294+
295+ const resetAll = () => {
296+ if (!window.confirm("Reset every setting and source edit to the defaults?")) return;
297+ clearPersistentState();
298+ setSpec(defaultSpec());
299+ setMode("modal");
300+ setEdits({});
301+ setRightTab("equations");
302+ setAdvancedOpen(false);
303+ solveA.reset();
304+ solveB.reset();
305+ solveCmp.reset();
306+ setRanWith(null);
307+ };
308+
309+ useEffect(() => {
310+ const onKey = (e: KeyboardEvent) => {
311+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
312+ e.preventDefault();
313+ void run();
314+ }
315+ };
316+ window.addEventListener("keydown", onKey);
317+ return () => window.removeEventListener("keydown", onKey);
318+ }, [run]);
319+
320+ // Results belong to the mode that produced them; switching method tabs shows that tab's own
321+ // empty state rather than someone else's frames.
322+ const showing = !!ranWith && ranWith.mode === mode;
323+ // A comparison is three runs; whichever one failed is the state worth reporting.
324+ const activeState = method
325+ ? solveA.state
326+ : solveA.state.status === "error"
327+ ? solveA.state
328+ : solveB.state.status === "error"
329+ ? solveB.state
330+ : solveCmp.state;
331+ const currentState = showing ? activeState : IDLE;
332+ const currentResult: PlateResult | null = method && showing ? solveA.last : null;
333+ /** A result is on screen but the settings have moved on since it was computed. */
334+ const stale =
335+ !!ranWith &&
336+ ranWith.mode === mode &&
337+ !running &&
338+ ranWith.stamp !== stamp &&
339+ (currentResult !== null || solveCmp.last !== null);
340+ const estimateMs =
341+ planMethod && methodPlan
342+ ? cost.current.estimateMs(planMethod, methodPlan.cell_steps)
343+ : undefined;
344+ const slow = estimateMs !== undefined && estimateMs > 5000;
345+ const advancedIssue = issues.some(
346+ (i) =>
347+ i.severity === "error" &&
348+ ADVANCED_PREFIXES.some((p) => i.path === p || i.path.startsWith(p + ".")),
349+ );
350+ const runLabel = method
351+ ? `Run ${method.shortName}`
352+ : `Run ${methodA.shortName} vs ${methodB.shortName}`;
353+
354+ return (
355+ <div className="app">
356+ <header className="app-header">
357+ <div>
358+ <h1>Plate Mechanics Lab</h1>
359+ <div className="sub">
360+ Vibration, stress and sound of a struck metal plate, solved by MATLAB code running in
361+ your browser.
362+ </div>
363+ </div>
364+ <div className="method-picker">
365+ <span className="picker-label">Method</span>
366+ <div className="mode-tabs" role="tablist">
367+ {METHODS.map((m) => (
368+ <button
369+ key={m.id}
370+ role="tab"
371+ className={mode === m.id ? "active" : ""}
372+ onClick={() => setMode(m.id)}
373+ aria-selected={mode === m.id}
374+ title={`${m.name}: ${m.summary}`}
375+ >
376+ {m.shortName}
377+ </button>
378+ ))}
379+ <button
380+ role="tab"
381+ className={mode === "compare" ? "active" : ""}
382+ onClick={() => setMode("compare")}
383+ aria-selected={mode === "compare"}
384+ title="Run two methods on the same problem and difference them"
385+ >
386+ Compare
387+ </button>
388+ </div>
389+ </div>
390+ </header>
391+ <div className="app-body">
392+ <aside className="col">
393+ <div className="action-bar">
394+ {running ? (
395+ <button className="pl-btn pl-btn-danger run-btn" onClick={cancel}>
396+ Cancel
397+ </button>
398+ ) : (
399+ <button
400+ className={`pl-btn pl-btn-primary run-btn${stale ? " run-btn-stale" : ""}`}
401+ onClick={() => void run()}
402+ disabled={hasErrors || blockers.length > 0}
403+ title="Ctrl+Enter or Cmd+Enter"
404+ >
405+ {runLabel}
406+ </button>
407+ )}
408+ <div className="action-meta">
409+ {running && <span className="pl-badge pl-badge-info">running</span>}
410+ {!running && currentState.status === "done" && (
411+ <span className="pl-badge pl-badge-ok">finished</span>
412+ )}
413+ {!running && currentState.status === "error" && (
414+ <span className="pl-badge pl-badge-error">error</span>
415+ )}
416+ {slow && (
417+ <span className="pl-badge pl-badge-warn">
418+ about {(estimateMs! / 1000).toFixed(0)} s
419+ </span>
420+ )}
421+ {!slow && estimateMs !== undefined && (
422+ <span className="pl-muted">
423+ about{" "}
424+ {estimateMs < 1000
425+ ? `${estimateMs.toFixed(0)} ms`
426+ : `${(estimateMs / 1000).toFixed(1)} s`}
427+ </span>
428+ )}
429+ <span style={{ flex: 1 }} />
430+ <button
431+ className="pl-btn"
432+ onClick={resetAll}
433+ title="Forget every setting and source edit saved in this browser"
434+ >
435+ Reset to defaults
436+ </button>
437+ </div>
438+ {hasErrors && !running && (
439+ <div className="pl-issue">
440+ Some settings are out of range; the groups that need attention are marked below.
441+ </div>
442+ )}
443+ </div>
444+ <div className="method-note">
445+ <strong>{method ? method.name : `${methodA.name} vs ${methodB.name}`}</strong>
446+ <div className="pl-muted">
447+ {method
448+ ? method.summary
449+ : "The same problem solved twice, matched in window and grid, then differenced."}
450+ </div>
451+ </div>
452+ {mainEdited && (
453+ <div className="banner">
454+ <span>
455+ The generated script was edited, so the controls no longer feed the run. Keep
456+ editing the source, or restore it to reconnect the controls.
457+ </span>
458+ <button
459+ className="pl-btn"
460+ onClick={() =>
461+ setEdits((e) =>
462+ Object.fromEntries(Object.entries(e).filter(([k]) => !k.startsWith("main"))),
463+ )
464+ }
465+ >
466+ Restore
467+ </button>
468+ </div>
469+ )}
470+ {mode === "compare" && (
471+ <div className="pl-panel pair-picker">
472+ <h3>Which two</h3>
473+ <div className="pl-row">
474+ <select
475+ className="pl-select"
476+ value={pair[0]}
477+ onChange={(e) => setPair([e.target.value, pair[1]])}
478+ aria-label="first method"
479+ >
480+ {METHODS.map((m) => (
481+ <option key={m.id} value={m.id}>
482+ {m.name}
483+ </option>
484+ ))}
485+ </select>
486+ <span className="pl-muted">against</span>
487+ <select
488+ className="pl-select"
489+ value={pair[1]}
490+ onChange={(e) => setPair([pair[0], e.target.value])}
491+ aria-label="second method"
492+ >
493+ {METHODS.map((m) => (
494+ <option key={m.id} value={m.id}>
495+ {m.name}
496+ </option>
497+ ))}
498+ </select>
499+ </div>
500+ <div className="pl-small pl-muted">
501+ Both runs are cut to the shorter window; a grid-free method adopts the other's grid
502+ so fields can be compared point by point.
503+ </div>
504+ </div>
505+ )}
506+ {blockers.map((b) => (
507+ <div className="pl-note" key={b}>
508+ {b}
509+ </div>
510+ ))}
511+ <div className={mainEdited ? "pl-disabled" : undefined}>
512+ <div className="panel-head">The problem</div>
513+ <ParameterPanel
514+ spec={spec}
515+ onChange={setSpec}
516+ method={method ?? undefined}
517+ issues={issues}
518+ sections={PROBLEM_SECTIONS}
519+ open={["plate", "material", "excitation"]}
520+ />
521+ <div className="panel-head">Numerics</div>
522+ <ParameterPanel
523+ spec={spec}
524+ onChange={setSpec}
525+ method={method ?? undefined}
526+ issues={issues}
527+ fdDtMax={planMethod?.id === "fd" ? methodPlan?.dt_max : undefined}
528+ sections={methodSections}
529+ open={methodSections}
530+ />
531+ {methodPlan && planMethod && (
532+ <div className="pl-small pl-muted plan-line">
533+ {planMethod.shortName}: {methodPlan.nsteps.toLocaleString()} steps of{" "}
534+ {(methodPlan.dt * 1e6).toPrecision(3)} µs, {methodPlan.cell_steps.toLocaleString()}{" "}
535+ cell-steps.
536+ </div>
537+ )}
538+ {/* The modal method has no grid or step of its own, so its simulated window lives here. */}
539+ <ParameterPanel
540+ spec={spec}
541+ onChange={setSpec}
542+ method={method ?? undefined}
543+ issues={issues}
544+ sections={OUTPUT_SECTIONS}
545+ open={methodSections.includes("modal") ? OUTPUT_SECTIONS : []}
546+ />
547+ <details
548+ className="advanced"
549+ open={advancedOpen || advancedIssue}
550+ onToggle={(e) => setAdvancedOpen((e.currentTarget as HTMLDetailsElement).open)}
551+ >
552+ <summary>
553+ More settings: damping, probe, listener, output
554+ {advancedIssue && (
555+ <span className="pl-badge pl-badge-error" style={{ marginLeft: 6 }}>
556+ check
557+ </span>
558+ )}
559+ </summary>
560+ <ParameterPanel
561+ spec={spec}
562+ onChange={setSpec}
563+ method={method ?? undefined}
564+ issues={issues}
565+ sections={ADVANCED_SECTIONS}
566+ open={ADVANCED_SECTIONS}
567+ />
568+ </details>
569+ </div>
570+ <div className="pl-small pl-muted">
571+ Settings, source edits and the chosen method are saved in this browser. Ctrl+Enter runs.
572+ </div>
573+ </aside>
574+
575+ <main className="col">
576+ {stale && (
577+ <div className="banner">
578+ <span>
579+ The settings changed after this run, so what you see below is from the earlier ones.
580+ </span>
581+ <button className="pl-btn" onClick={() => void run()}>
582+ Run again
583+ </button>
584+ </div>
585+ )}
586+ {currentState.status === "error" && (
587+ <div className="pl-error">{currentState.run.error}</div>
588+ )}
589+ {mode !== "compare" && currentResult && method && (
590+ <ResultsView result={currentResult} spec={spec} method={method} />
591+ )}
592+ {mode !== "compare" && !currentResult && (
593+ <EmptyState status={currentState.status} label={runLabel} />
594+ )}
595+ {mode === "compare" && (
596+ <CompareResults
597+ cmp={showing ? solveCmp.last : null}
598+ status={currentState.status}
599+ a={showing ? solveA.last : null}
600+ b={showing ? solveB.last : null}
601+ labelA={methodA.name}
602+ labelB={methodB.name}
603+ spec={spec}
604+ label={runLabel}
605+ />
606+ )}
607+ </main>
608+
609+ <section className="col-right">
610+ <div className="tabs" role="tablist">
611+ {RIGHT_TABS.map((t) => (
612+ <button
613+ key={t.id}
614+ className={rightTab === t.id ? "active" : ""}
615+ onClick={() => setRightTab(t.id)}
616+ role="tab"
617+ aria-selected={rightTab === t.id}
618+ >
619+ {t.label}
620+ </button>
621+ ))}
622+ </div>
623+ {rightTab === "equations" && (
624+ <div className="tab-body">
625+ <div className="pl-small pl-muted tab-intro">
626+ What {mode === "compare" ? methodB.name.toLowerCase() : "this method"} discretizes
627+ and solves. Read the code that does it under Source.
628+ </div>
629+ <EquationsView equations={(method ?? methodB).equations} />
630+ {mode === "compare" && (
631+ <div className="pl-small pl-muted">
632+ Shown: {methodB.name}. The other method's equations are under its own tab.
633+ </div>
634+ )}
635+ </div>
636+ )}
637+ {rightTab === "source" && (
638+ <CodeView
639+ files={bundles.display.files}
640+ edits={edits}
641+ onEditsChange={setEdits}
642+ order={
643+ mode === "compare"
644+ ? [
645+ "main_a.m",
646+ "main_b.m",
647+ "compare.m",
648+ "plate_compare.m",
649+ ...methodA.readingOrder,
650+ ...methodB.readingOrder,
651+ ]
652+ : ["main.m", ...(method?.readingOrder ?? [])]
653+ }
654+ summaries={SUMMARIES}
655+ height="100%"
656+ />
657+ )}
658+ {rightTab === "run" && (
659+ <div className="tab-body" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
660+ {method && (
661+ <RunStatus
662+ state={currentState}
663+ result={currentResult}
664+ method={method}
665+ backend={backend}
666+ estimateMs={estimateMs}
667+ onCancel={cancel}
668+ />
669+ )}
670+ {mode === "compare" && (
671+ <>
672+ <h3 className="pl-small pl-muted">{methodA.name}</h3>
673+ <RunStatus
674+ state={showing ? solveA.state : IDLE}
675+ result={showing ? solveA.last : null}
676+ method={methodA}
677+ backend={backend}
678+ onCancel={cancel}
679+ />
680+ <h3 className="pl-small pl-muted">{methodB.name}</h3>
681+ <RunStatus
682+ state={showing ? solveB.state : IDLE}
683+ result={showing ? solveB.last : null}
684+ method={methodB}
685+ backend={backend}
686+ estimateMs={estimateMs}
687+ onCancel={cancel}
688+ />
689+ <h3 className="pl-small pl-muted">Comparison</h3>
690+ <RunStatus
691+ state={showing ? solveCmp.state : IDLE}
692+ backend={backend}
693+ onCancel={cancel}
694+ />
695+ </>
696+ )}
697+ </div>
698+ )}
699+ </section>
700+ </div>
701+ </div>
702+ );
703+}
704+
705+/** What the middle column says before anything has been solved. */
706+function EmptyState({ status, label }: { status: string; label: string }) {
707+ if (status === "running") return <div className="empty">Solving…</div>;
708+ if (status === "error")
709+ return (
710+ <div className="empty">
711+ The run failed. The message is above, and the full log is under Run details.
712+ </div>
713+ );
714+ return (
715+ <div className="empty">
716+ <h2>Nothing solved yet</h2>
717+ <ol>
718+ <li>Set the plate, material and strike on the left.</li>
719+ <li>
720+ Press <strong>{label}</strong>, or Ctrl+Enter.
721+ </li>
722+ <li>Displacement, stress, the probe traces and the radiated sound appear here.</li>
723+ </ol>
724+ <p className="pl-muted">
725+ The equations for the chosen method are on the right; Source shows the MATLAB that runs, and
726+ you can edit it.
727+ </p>
728+ </div>
729+ );
730+}
731+
732+function CompareResults({
733+ cmp,
734+ status,
735+ a: modal,
736+ b: fd,
737+ labelA,
738+ labelB,
739+ spec,
740+ label,
741+}: {
742+ cmp: ReturnType<typeof parseCompareResult> | null;
743+ status: string;
744+ a: PlateResult | null;
745+ b: PlateResult | null;
746+ labelA: string;
747+ labelB: string;
748+ spec: ProblemSpec;
749+ label: string;
750+}) {
751+ const [k, setK] = useState(0);
752+ if (!cmp || !modal || !fd) {
753+ if (status === "running") return <div className="empty">Solving both methods…</div>;
754+ return <EmptyState status={status} label={label} />;
755+ }
756+ const nf = Math.min(modal.frames.t.length, fd.frames.t.length);
757+ const idx = Math.min(k, nf - 1);
758+ const la = labelA.toLowerCase();
759+ const lb = labelB.toLowerCase();
760+ return (
761+ <div className="results">
762+ <CompareView cmp={cmp} labelA={la} labelB={lb} />
763+ <div className="pl-row pl-small">
764+ <span>Frame</span>
765+ <input
766+ type="range"
767+ min={0}
768+ max={Math.max(0, nf - 1)}
769+ value={idx}
770+ onChange={(e) => setK(Number(e.target.value))}
771+ style={{ flex: 1 }}
772+ />
773+ <span className="pl-mono">{(modal.frames.t[idx] * 1000).toFixed(2)} ms</span>
774+ </div>
775+ <div className="two-up">
776+ <FieldView
777+ frames={modal.frames.w}
778+ frameIndex={idx}
779+ Lx={spec.plate.Lx}
780+ Ly={spec.plate.Ly}
781+ title={`w, ${la}`}
782+ unit="µm"
783+ scale={1e6}
784+ symmetric
785+ width={300}
786+ />
787+ <FieldView
788+ frames={fd.frames.w}
789+ frameIndex={idx}
790+ Lx={spec.plate.Lx}
791+ Ly={spec.plate.Ly}
792+ title={`w, ${lb}`}
793+ unit="µm"
794+ scale={1e6}
795+ symmetric
796+ width={300}
797+ />
798+ </div>
799+ {cmp.frames_diff && (
800+ <FieldView
801+ frames={cmp.frames_diff}
802+ frameIndex={idx}
803+ Lx={spec.plate.Lx}
804+ Ly={spec.plate.Ly}
805+ title={`difference, ${la} − ${lb}`}
806+ unit="µm"
807+ scale={1e6}
808+ symmetric
809+ width={300}
810+ caption="Same colour convention; the range is that of the difference itself."
811+ />
812+ )}
813+ <div className="two-up">
814+ <FieldView
815+ frames={modal.frames.vm}
816+ frameIndex={idx}
817+ Lx={spec.plate.Lx}
818+ Ly={spec.plate.Ly}
819+ title={`von Mises, ${la}`}
820+ unit="MPa"
821+ scale={1e-6}
822+ width={300}
823+ />
824+ <FieldView
825+ frames={fd.frames.vm}
826+ frameIndex={idx}
827+ Lx={spec.plate.Lx}
828+ Ly={spec.plate.Ly}
829+ title={`von Mises, ${lb}`}
830+ unit="MPa"
831+ scale={1e-6}
832+ width={300}
833+ />
834+ </div>
835+ </div>
836+ );
837+}
apps/lab/src/main.tsxadded+14−0View file
@@ -0,0 +1,14 @@
1+import { StrictMode } from "react";
2+import { createRoot } from "react-dom/client";
3+import { RunnerProvider } from "@plate-lab/components";
4+import "@plate-lab/components/styles.css";
5+import "./styles.css";
6+import { App } from "./App";
7+
8+createRoot(document.getElementById("root")!).render(
9+ <StrictMode>
10+ <RunnerProvider>
11+ <App />
12+ </RunnerProvider>
13+ </StrictMode>,
14+);
apps/lab/src/styles.cssadded+299−0View file
@@ -0,0 +1,299 @@
1+* {
2+ box-sizing: border-box;
3+}
4+html,
5+body,
6+#root {
7+ height: 100%;
8+ margin: 0;
9+}
10+body {
11+ font-family: var(--pl-font);
12+ color: var(--pl-text);
13+ background: #eef0f3;
14+ font-size: 13px;
15+}
16+.app {
17+ display: grid;
18+ grid-template-rows: auto 1fr;
19+ height: 100%;
20+}
21+.app-header {
22+ display: flex;
23+ align-items: center;
24+ gap: 14px;
25+ padding: 8px 14px;
26+ background: #fff;
27+ border-bottom: 1px solid var(--pl-border);
28+ flex-wrap: wrap;
29+}
30+.app-header h1 {
31+ font-size: 16px;
32+ margin: 0;
33+ font-weight: 650;
34+}
35+.app-header .sub {
36+ color: var(--pl-muted);
37+ font-size: 12px;
38+}
39+.method-picker {
40+ display: flex;
41+ align-items: center;
42+ gap: 8px;
43+ margin-left: auto;
44+}
45+.method-picker .picker-label {
46+ font-size: 11px;
47+ text-transform: uppercase;
48+ letter-spacing: 0.06em;
49+ color: var(--pl-muted);
50+}
51+.mode-tabs {
52+ display: flex;
53+ gap: 2px;
54+ background: var(--pl-panel);
55+ border: 1px solid var(--pl-border);
56+ border-radius: 6px;
57+ padding: 2px;
58+}
59+.mode-tabs button {
60+ font: inherit;
61+ padding: 4px 12px;
62+ border: 0;
63+ background: transparent;
64+ border-radius: 4px;
65+ cursor: pointer;
66+ color: var(--pl-muted);
67+}
68+.mode-tabs button.active {
69+ background: #fff;
70+ color: var(--pl-text);
71+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
72+}
73+.app-body {
74+ display: grid;
75+ grid-template-columns: 320px minmax(420px, 1fr) minmax(460px, 560px);
76+ gap: 10px;
77+ padding: 10px;
78+ min-height: 0;
79+}
80+.col {
81+ min-height: 0;
82+ overflow: auto;
83+ display: flex;
84+ flex-direction: column;
85+ gap: 10px;
86+}
87+.action-bar {
88+ position: sticky;
89+ top: 0;
90+ z-index: 2;
91+ background: #eef0f3;
92+ padding-bottom: 6px;
93+ display: flex;
94+ flex-direction: column;
95+ gap: 6px;
96+}
97+.run-btn {
98+ width: 100%;
99+ padding: 9px 12px;
100+ font-size: 14px;
101+ font-weight: 600;
102+}
103+.action-meta {
104+ display: flex;
105+ gap: 8px;
106+ align-items: center;
107+ font-size: 12px;
108+ flex-wrap: wrap;
109+}
110+.col-right {
111+ display: grid;
112+ grid-template-rows: auto 1fr;
113+ gap: 6px;
114+ min-height: 0;
115+}
116+.col-right .pl-code {
117+ min-height: 320px;
118+}
119+.tabs {
120+ display: flex;
121+ gap: 2px;
122+ border-bottom: 1px solid var(--pl-border);
123+}
124+.tabs button {
125+ font: inherit;
126+ font-size: 12px;
127+ padding: 5px 10px;
128+ border: 1px solid transparent;
129+ border-bottom: 0;
130+ background: transparent;
131+ cursor: pointer;
132+ color: var(--pl-muted);
133+ border-radius: 5px 5px 0 0;
134+}
135+.tabs button.active {
136+ background: #fff;
137+ border-color: var(--pl-border);
138+ color: var(--pl-text);
139+}
140+.tab-body {
141+ background: #fff;
142+ border: 1px solid var(--pl-border);
143+ border-top: 0;
144+ border-radius: 0 0 6px 6px;
145+ padding: 10px;
146+ overflow: auto;
147+ min-height: 0;
148+}
149+.results {
150+ display: flex;
151+ flex-direction: column;
152+ gap: 12px;
153+}
154+.results-toolbar {
155+ display: flex;
156+ gap: 8px;
157+ align-items: center;
158+ flex-wrap: wrap;
159+ font-size: 12px;
160+}
161+.two-up {
162+ display: grid;
163+ grid-template-columns: 1fr 1fr;
164+ gap: 10px;
165+}
166+.modes-table {
167+ font-size: 11px;
168+ border-collapse: collapse;
169+}
170+.modes-table td,
171+.modes-table th {
172+ padding: 1px 8px;
173+ text-align: right;
174+ border-bottom: 1px solid var(--pl-border);
175+}
176+.modes-table th:first-child,
177+.modes-table td:first-child {
178+ text-align: left;
179+}
180+.banner {
181+ padding: 8px 10px;
182+ border-radius: 6px;
183+ background: var(--pl-warn-bg);
184+ border: 1px solid #f1d7a8;
185+ font-size: 12px;
186+ display: flex;
187+ gap: 8px;
188+ align-items: center;
189+}
190+.empty {
191+ color: var(--pl-muted);
192+ padding: 24px 26px;
193+ background: #fff;
194+ border: 1px dashed var(--pl-border);
195+ border-radius: 6px;
196+ max-width: 560px;
197+}
198+.empty h2 {
199+ font-size: 15px;
200+ margin: 0 0 8px;
201+ color: var(--pl-text);
202+ font-weight: 650;
203+}
204+.empty ol {
205+ margin: 0 0 10px;
206+ padding-left: 20px;
207+ line-height: 1.7;
208+}
209+.empty p {
210+ margin: 0;
211+ font-size: 12px;
212+}
213+
214+/* Left column structure: the problem, then the numerics, then everything else */
215+.panel-head {
216+ font-size: 11px;
217+ text-transform: uppercase;
218+ letter-spacing: 0.06em;
219+ color: var(--pl-muted);
220+ font-weight: 650;
221+ margin: 12px 0 6px;
222+}
223+.panel-head:first-child {
224+ margin-top: 0;
225+}
226+.method-note {
227+ font-size: 12px;
228+ line-height: 1.45;
229+ padding: 8px 10px;
230+ background: #fff;
231+ border: 1px solid var(--pl-border);
232+ border-left: 3px solid var(--pl-accent);
233+ border-radius: 5px;
234+}
235+.plan-line {
236+ margin-top: 6px;
237+}
238+.pl-params + .pl-params,
239+.plan-line + .pl-params {
240+ margin-top: 10px;
241+}
242+details.advanced {
243+ margin-top: 12px;
244+ border-top: 1px solid var(--pl-border);
245+ padding-top: 8px;
246+}
247+details.advanced > summary {
248+ font-size: 12px;
249+ color: var(--pl-muted);
250+ cursor: pointer;
251+ margin-bottom: 8px;
252+}
253+details.advanced > summary:hover {
254+ color: var(--pl-accent);
255+}
256+/* The result on screen is from other settings: the button says so without moving. */
257+.run-btn-stale {
258+ box-shadow: 0 0 0 3px rgba(31, 95, 168, 0.25);
259+}
260+kbd {
261+ font-family: var(--pl-mono);
262+ font-size: 11px;
263+ border: 1px solid var(--pl-border);
264+ border-bottom-width: 2px;
265+ border-radius: 3px;
266+ padding: 0 4px;
267+ background: #fff;
268+}
269+@media (max-width: 1250px) {
270+ .app-body {
271+ grid-template-columns: 300px 1fr;
272+ }
273+ .col-right {
274+ grid-column: 1 / -1;
275+ }
276+}
277+@media (max-width: 800px) {
278+ .app-body {
279+ grid-template-columns: 1fr;
280+ }
281+ .col-right {
282+ grid-column: auto;
283+ }
284+}
285+.sound-bar {
286+ padding: 6px 8px;
287+ background: #fff;
288+ border: 1px solid var(--pl-border);
289+ border-radius: 6px;
290+}
291+.pair-picker .pl-select {
292+ width: auto;
293+ flex: 1;
294+}
295+.tab-intro {
296+ margin-bottom: 10px;
297+ padding-bottom: 8px;
298+ border-bottom: 1px solid var(--pl-border);
299+}
apps/lab/src/usePersistentState.tsadded+47−0View file
@@ -0,0 +1,47 @@
1+import { useCallback, useEffect, useState } from "react";
2+
3+const PREFIX = "plate-lab:";
4+
5+/** React state mirrored into localStorage under `key`. `validate` rejects stale shapes. */
6+export function usePersistentState<T>(
7+ key: string,
8+ initial: () => T,
9+ validate?: (v: unknown) => v is T,
10+): [T, (v: T | ((prev: T) => T)) => void, () => void] {
11+ const full = PREFIX + key;
12+ const [value, setValue] = useState<T>(() => {
13+ try {
14+ const raw = localStorage.getItem(full);
15+ if (raw !== null) {
16+ const parsed: unknown = JSON.parse(raw);
17+ if (!validate || validate(parsed)) return parsed as T;
18+ }
19+ } catch {
20+ /* fall back to the default */
21+ }
22+ return initial();
23+ });
24+ useEffect(() => {
25+ try {
26+ localStorage.setItem(full, JSON.stringify(value));
27+ } catch {
28+ /* storage unavailable: state simply does not persist */
29+ }
30+ }, [full, value]);
31+ const reset = useCallback(() => setValue(initial()), [initial]);
32+ return [value, setValue, reset];
33+}
34+
35+/** Forget everything the lab has stored in this browser. */
36+export function clearPersistentState(): void {
37+ try {
38+ const keys: string[] = [];
39+ for (let i = 0; i < localStorage.length; i++) {
40+ const k = localStorage.key(i);
41+ if (k && k.startsWith(PREFIX)) keys.push(k);
42+ }
43+ keys.forEach((k) => localStorage.removeItem(k));
44+ } catch {
45+ /* ignore */
46+ }
47+}
apps/lab/src/views/ResultsView.tsxadded+310−0View file
@@ -0,0 +1,310 @@
1+import { useEffect, useMemo, useState } from "react";
2+import type { FieldFrames, MethodDefinition, PlateResult, ProblemSpec } from "@plate-lab/core";
3+import {
4+ AudioPlayer,
5+ FieldView,
6+ FramePlayer,
7+ LinePlot,
8+ ValidityNotes,
9+ useFramePlayer,
10+ useSharedAxis,
11+ type FieldMarker,
12+} from "@plate-lab/components";
13+
14+type Quantity = "w" | "vm" | "sp" | "env_vm" | "env_sp";
15+
16+const QUANTITIES: {
17+ id: Quantity;
18+ label: string;
19+ unit: string;
20+ scale: number;
21+ symmetric: boolean;
22+}[] = [
23+ { id: "w", label: "Displacement w", unit: "µm", scale: 1e6, symmetric: true },
24+ { id: "vm", label: "Von Mises stress (surface)", unit: "MPa", scale: 1e-6, symmetric: false },
25+ {
26+ id: "sp",
27+ label: "Principal stress, signed (surface)",
28+ unit: "MPa",
29+ scale: 1e-6,
30+ symmetric: true,
31+ },
32+ { id: "env_vm", label: "Peak von Mises over time", unit: "MPa", scale: 1e-6, symmetric: false },
33+ {
34+ id: "env_sp",
35+ label: "Peak |principal stress| over time",
36+ unit: "MPa",
37+ scale: 1e-6,
38+ symmetric: false,
39+ },
40+];
41+
42+export interface ResultsViewProps {
43+ result: PlateResult;
44+ spec: ProblemSpec;
45+ method: MethodDefinition;
46+ fieldWidth?: number;
47+}
48+
49+/** Fields, probe series, spectrum, sound and validity for one result. */
50+export function ResultsView({ result, spec, method, fieldWidth = 460 }: ResultsViewProps) {
51+ // Displacement first: it is the primary unknown, and the stresses below are derived from it.
52+ const [quantity, setQuantity] = useState<Quantity>("w");
53+ const [rangeMode, setRangeMode] = useState<"global" | "frame">("global");
54+ const q = QUANTITIES.find((x) => x.id === quantity)!;
55+ const nf = result.frames.t.length;
56+ // The animation waits to be started; a run that finishes should not take over the screen.
57+ const player = useFramePlayer(nf, 15, false);
58+ // A paused animation would otherwise open on t = 0, which is a blank plate.
59+ const startFrame = useMemo(() => peakFrame(result.frames.w), [result]);
60+ const setIndex = player.setIndex;
61+ useEffect(() => setIndex(startFrame), [startFrame, setIndex]);
62+ const frames: FieldFrames | undefined =
63+ quantity === "w"
64+ ? result.frames.w
65+ : quantity === "vm"
66+ ? result.frames.vm
67+ : quantity === "sp"
68+ ? result.frames.sp
69+ : undefined;
70+ const field =
71+ quantity === "env_vm"
72+ ? result.envelope.vm
73+ : quantity === "env_sp"
74+ ? result.envelope.sp
75+ : undefined;
76+ const tFrame = result.frames.t[Math.min(player.index, nf - 1)] ?? 0;
77+
78+ const markers: FieldMarker[] = useMemo(
79+ () => [
80+ { x: spec.excitation.x, y: spec.excitation.y, label: "strike", color: "#d1495b" },
81+ { x: result.probe.x, y: result.probe.y, label: "probe", color: "#1f5fa8" },
82+ ],
83+ [spec.excitation.x, spec.excitation.y, result.probe.x, result.probe.y],
84+ );
85+
86+ const onPlotClick = (t: number) => {
87+ let best = 0;
88+ for (let k = 1; k < nf; k++)
89+ if (Math.abs(result.frames.t[k] - t) < Math.abs(result.frames.t[best] - t)) best = k;
90+ player.setIndex(best);
91+ player.setPlaying(false);
92+ };
93+
94+ const fNyq = result.info.fs / 2;
95+ const axis = useSharedAxis();
96+ const spectrumAxis = useSharedAxis();
97+ const audioT = useMemo(
98+ () => Float64Array.from({ length: result.audio.p.length }, (_, i) => i / result.audio.fs),
99+ [result.audio],
100+ );
101+
102+ return (
103+ <div className="results">
104+ <div className="results-toolbar sound-bar">
105+ <AudioPlayer p={result.audio.p} fs={result.audio.fs} label="sound" />
106+ </div>
107+ <div className="results-toolbar">
108+ <label className="pl-small pl-muted">show</label>
109+ <select
110+ className="pl-select"
111+ style={{ width: "auto" }}
112+ value={quantity}
113+ onChange={(e) => setQuantity(e.target.value as Quantity)}
114+ >
115+ {QUANTITIES.map((x) => (
116+ <option key={x.id} value={x.id}>
117+ {x.label}
118+ </option>
119+ ))}
120+ </select>
121+ {frames && (
122+ <label className="pl-small pl-muted">
123+ colour range{" "}
124+ <select
125+ className="pl-select"
126+ style={{ width: "auto" }}
127+ value={rangeMode}
128+ onChange={(e) => setRangeMode(e.target.value as "global" | "frame")}
129+ >
130+ <option value="global">fixed over all frames</option>
131+ <option value="frame">rescaled each frame</option>
132+ </select>
133+ </label>
134+ )}
135+ {!result.info.stable && (
136+ <span className="pl-badge pl-badge-error">
137+ diverged at step {result.info.diverged_step}: frames stop there
138+ </span>
139+ )}
140+ </div>
141+ <FieldView
142+ frames={frames}
143+ field={field}
144+ frameIndex={player.index}
145+ Lx={spec.plate.Lx}
146+ Ly={spec.plate.Ly}
147+ title={q.label}
148+ unit={q.unit}
149+ scale={q.scale}
150+ symmetric={q.symmetric}
151+ range={rangeMode}
152+ markers={markers}
153+ width={fieldWidth}
154+ caption={
155+ quantity.startsWith("env")
156+ ? "Peak over the saved frames, so a sample of the true peak. Surface z = h/2; the opposite face carries the opposite sign."
157+ : quantity === "w"
158+ ? "Transverse displacement; positive towards the listener."
159+ : "Surface stress at z = h/2 from the curvature of w; the opposite face carries the opposite sign."
160+ }
161+ />
162+ {frames && <FramePlayer {...player} times={result.frames.t} />}
163+
164+ <LinePlot
165+ title={`Displacement at the probe (${(result.probe.x * 1000).toFixed(0)}, ${(result.probe.y * 1000).toFixed(0)}) mm`}
166+ series={[{ x: result.t, y: result.probe.w, label: "w" }]}
167+ xScale={1000}
168+ yScale={1e6}
169+ xLabel="time [ms]"
170+ yLabel="w [µm]"
171+ yUnit="µm"
172+ cursorX={tFrame}
173+ onClick={onPlotClick}
174+ height={170}
175+ {...axis}
176+ />
177+ <LinePlot
178+ title="Surface stress at the probe"
179+ series={[
180+ { x: result.t, y: result.probe.vm, label: "von Mises" },
181+ { x: result.t, y: result.probe.sp, label: "principal (signed)", color: "#d1495b" },
182+ ]}
183+ xScale={1000}
184+ yScale={1e-6}
185+ xLabel="time [ms]"
186+ yLabel="σ [MPa]"
187+ yUnit="MPa"
188+ cursorX={tFrame}
189+ onClick={onPlotClick}
190+ height={170}
191+ {...axis}
192+ />
193+ <LinePlot
194+ title="Amplitude spectrum at the probe (Hann window)"
195+ series={[
196+ { x: result.spectrum.f, y: result.spectrum.w, label: "w [m]" },
197+ {
198+ x: result.spectrum.f,
199+ y: result.spectrum.vm,
200+ label: "von Mises [Pa]",
201+ color: "#d1495b",
202+ },
203+ ]}
204+ defaultXRange={[0, Math.min(fNyq, spec.modal.fmax * 1.1)]}
205+ logY
206+ xLabel="frequency [Hz]"
207+ yLabel="amplitude"
208+ height={170}
209+ {...spectrumAxis}
210+ />
211+ <div className="pl-small pl-muted">
212+ Frequency resolution {(1 / (result.t[result.t.length - 1] - result.t[0])).toFixed(1)} Hz
213+ from a {((result.t[result.t.length - 1] - result.t[0]) * 1000).toFixed(1)} ms record.
214+ {result.method === "fd"
215+ ? " The finite-difference record is short, so peaks are broad."
216+ : ""}
217+ </div>
218+
219+ <div>
220+ <div className="pl-plot-title">Radiated sound at the listener</div>
221+ <LinePlot
222+ series={[{ x: audioT, y: result.audio.p, label: "pressure" }]}
223+ xScale={1000}
224+ xLabel="time after first arrival [ms]"
225+ yLabel="p [Pa]"
226+ yUnit="Pa"
227+ height={120}
228+ {...axis}
229+ />
230+ <div className="pl-small pl-muted">
231+ Rayleigh integral, listener {spec.listener.z} m above the plate;{" "}
232+ {method.supports.audio === "clip"
233+ ? "only the simulated window is available, so this is a short clip."
234+ : "propagation delay of the nearest patch removed."}
235+ </div>
236+ </div>
237+
238+ {result.modes && <ModesTable result={result} />}
239+
240+ <details>
241+ <summary className="pl-small">What this result idealizes</summary>
242+ <ValidityNotes method={method} result={result} />
243+ </details>
244+ </div>
245+ );
246+}
247+
248+/**
249+ * The frame of largest displacement. The player starts here so a run that has just
250+ * finished shows the plate in motion rather than the flat state before the strike.
251+ */
252+function peakFrame(f: FieldFrames | undefined): number {
253+ if (!f) return 0;
254+ const n = f.nx * f.ny;
255+ let best = 0;
256+ let bestv = -1;
257+ for (let k = 0; k < f.nf; k++) {
258+ let v = 0;
259+ for (let i = k * n; i < (k + 1) * n; i++) {
260+ const a = Math.abs(f.data[i]);
261+ if (a > v) v = a;
262+ }
263+ if (v > bestv) {
264+ bestv = v;
265+ best = k;
266+ }
267+ }
268+ return best;
269+}
270+
271+function ModesTable({ result }: { result: PlateResult }) {
272+ const m = result.modes!;
273+ const n = Math.min(12, m.f_hz.length);
274+ return (
275+ <details>
276+ <summary className="pl-small">
277+ Modes used: {m.f_hz.length} below {result.info.extra.fmax} Hz
278+ {m.truncated ? " (truncated)" : ""}
279+ </summary>
280+ <table className="modes-table">
281+ <thead>
282+ <tr>
283+ <th>(m, n)</th>
284+ <th>f [Hz]</th>
285+ <th>ζ = σ/ω</th>
286+ <th>T60 [s]</th>
287+ <th>force factor G</th>
288+ </tr>
289+ </thead>
290+ <tbody>
291+ {Array.from({ length: n }, (_, k) => (
292+ <tr key={k}>
293+ <td>
294+ ({m.m[k]}, {m.n[k]})
295+ </td>
296+ <td>{m.f_hz[k].toFixed(1)}</td>
297+ <td>{(m.sigma[k] / (2 * Math.PI * m.f_hz[k])).toExponential(2)}</td>
298+ <td>{(6.91 / m.sigma[k]).toFixed(2)}</td>
299+ <td>{m.G[k].toExponential(2)}</td>
300+ </tr>
301+ ))}
302+ </tbody>
303+ </table>
304+ <div className="pl-small pl-muted">
305+ First {n} of {m.f_hz.length}. T60 is the time for a mode to decay by 60 dB under this
306+ damping model.
307+ </div>
308+ </details>
309+ );
310+}
apps/lab/tsconfig.jsonadded+9−0View file
@@ -0,0 +1,9 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ "noEmit": true
5+ },
6+ "include": [
7+ "src"
8+ ]
9+}
apps/lab/vite.config.tsadded+13−0View file
@@ -0,0 +1,13 @@
1+import { defineConfig } from "vite";
2+import react from "@vitejs/plugin-react";
3+import { numblDefine } from "../../vite.shared";
4+
5+// The vault serves a site either at the root of an origin of its own (when the
6+// repository has a site label) or under /<collection>/<repo>/site/, so the base
7+// path is not knowable here. CI reads it from configure-pages and passes it in.
8+export default defineConfig({
9+ base: process.env.DEPLOY_BASE ?? "/",
10+ plugins: [react()],
11+ define: numblDefine(),
12+ worker: { format: "es" },
13+});
apps/tutorials/index.htmladded+12−0View file
@@ -0,0 +1,12 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+ <title>Plate Mechanics Tutorials</title>
7+ </head>
8+ <body>
9+ <div id="root"></div>
10+ <script type="module" src="/src/main.tsx"></script>
11+ </body>
12+</html>
apps/tutorials/package.jsonadded+23−0View file
@@ -0,0 +1,23 @@
1+{
2+ "name": "@plate-lab/tutorials",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "vite build",
9+ "preview": "vite preview",
10+ "typecheck": "tsc -p tsconfig.json --noEmit"
11+ },
12+ "dependencies": {
13+ "@plate-lab/components": "*",
14+ "@plate-lab/core": "*",
15+ "react": "^19.2.0",
16+ "react-dom": "^19.2.0",
17+ "@mdx-js/react": "^3.1.0"
18+ },
19+ "devDependencies": {
20+ "@mdx-js/rollup": "^3.1.0",
21+ "@types/mdx": "^2.0.13"
22+ }
23+}
apps/tutorials/src/embeds/CflExperiment.tsxadded+86−0View file
@@ -0,0 +1,86 @@
1+import { useEffect, useMemo, useState } from "react";
2+import {
3+ FD_METHOD,
4+ buildPlanBundle,
5+ buildRunBundle,
6+ parseFdPlan,
7+ parsePlateResult,
8+ withEdits,
9+} from "@plate-lab/core";
10+import { CflSlider, CodeView, FieldView, FramePlayer, LinePlot, RunStatus, useFramePlayer, useSolve } from "@plate-lab/components";
11+import { tutorialSpec } from "./tutorialSpec";
12+
13+/**
14+ * Turn the time step past the stability limit and watch the scheme blow up.
15+ * The FD source is shown and editable; the run uses the edited text.
16+ */
17+export function CflExperiment() {
18+ const [cfl, setCfl] = useState(0.9);
19+ const [edits, setEdits] = useState<Record<string, string>>({});
20+ const [showCode, setShowCode] = useState(false);
21+ const spec = useMemo(() => {
22+ const s = tutorialSpec();
23+ s.fd.cfl = cfl;
24+ return s;
25+ }, [cfl]);
26+ const bundle = useMemo(() => withEdits(buildRunBundle(spec, FD_METHOD), edits), [spec, edits]);
27+ const solve = useSolve(parsePlateResult);
28+ const plan = useSolve(parseFdPlan);
29+ useEffect(() => {
30+ const pb = buildPlanBundle(spec, FD_METHOD);
31+ if (pb) void plan.run({ bundle: pb });
32+ // eslint-disable-next-line react-hooks/exhaustive-deps
33+ }, [spec.fd.cfl]);
34+
35+ const result = solve.last;
36+ const nf = result?.frames.t.length ?? 0;
37+ const player = useFramePlayer(nf, 12, true);
38+ const absW = useMemo(() => (result ? Float64Array.from(result.probe.w, (v) => Math.abs(v)) : null), [result]);
39+
40+ return (
41+ <div className="embed">
42+ <div className="embed-title">Experiment: the time step against the stability limit</div>
43+ <CflSlider value={cfl} onChange={setCfl} dtMax={plan.last?.dt_max} />
44+ <div className="pl-row" style={{ margin: "8px 0" }}>
45+ <button className="pl-btn pl-btn-primary" onClick={() => void solve.run({ bundle })} disabled={solve.state.status === "running"}>
46+ Run finite differences
47+ </button>
48+ <button className="pl-btn" onClick={() => setShowCode((s) => !s)}>
49+ {showCode ? "Hide" : "Show"} the source that runs
50+ </button>
51+ {plan.last && (
52+ <span className="pl-muted pl-small">
53+ {plan.last.nx} × {plan.last.ny} grid, {plan.last.nsteps} steps
54+ </span>
55+ )}
56+ </div>
57+ {showCode && (
58+ <CodeView files={bundle.files} edits={edits} onEditsChange={setEdits} order={["main.m", "plate_fd.m", "plate_fd_plan.m"]} initialFile="plate_fd_plan.m" />
59+ )}
60+ {result && (
61+ <div style={{ display: "grid", gap: 8, marginTop: 8 }}>
62+ <FieldView frames={result.frames.w} frameIndex={player.index} Lx={spec.plate.Lx} Ly={spec.plate.Ly} title={result.info.stable ? "Displacement" : `Displacement until the blow-up at step ${result.info.diverged_step}`} unit="µm" scale={1e6} symmetric range="frame" width={420} />
63+ <FramePlayer {...player} times={result.frames.t} />
64+ <LinePlot
65+ title="|w| at the probe, logarithmic"
66+ series={[{ x: result.t, y: absW!, label: "|w|" }]}
67+ xScale={1000}
68+ xLabel="time [ms]"
69+ yLabel="|w| [m]"
70+ logY
71+ height={160}
72+ />
73+ <div className="pl-small pl-muted">
74+ {result.info.stable
75+ ? "Stable: the amplitude stays bounded and decays through the damping terms."
76+ : "Unstable: the shortest wave the grid can hold is amplified by a fixed factor every step, so |w| grows exponentially until the run stops itself."}
77+ </div>
78+ </div>
79+ )}
80+ <details style={{ marginTop: 8 }}>
81+ <summary className="pl-small">Run details</summary>
82+ <RunStatus state={solve.state} result={result} method={FD_METHOD} onCancel={solve.cancel} />
83+ </details>
84+ </div>
85+ );
86+}
apps/tutorials/src/embeds/Equations.tsxadded+11−0View file
@@ -0,0 +1,11 @@
1+import { EquationsView } from "@plate-lab/components";
2+import { FD_METHOD, GOVERNING_EQUATIONS } from "@plate-lab/core";
3+
4+export function GoverningEquation() {
5+ return <EquationsView equations={[GOVERNING_EQUATIONS[0]]} />;
6+}
7+
8+export function SchemeEquations() {
9+ const eqs = FD_METHOD.equations.filter((e) => /w\^\{n\+1\}|Delta t \\le/.test(e.tex));
10+ return <EquationsView equations={eqs} />;
11+}
apps/tutorials/src/embeds/RefinementTable.tsxadded+70−0View file
@@ -0,0 +1,70 @@
1+import { useEffect, useState } from "react";
2+import { FD_METHOD, buildPlanBundle, parseFdPlan, type FdPlan } from "@plate-lab/core";
3+import { useRunner } from "@plate-lab/components";
4+import { tutorialSpec } from "./tutorialSpec";
5+
6+const GRIDS: [number, number][] = [
7+ [16, 11],
8+ [31, 21],
9+ [61, 41],
10+ [121, 81],
11+];
12+
13+/** plate_fd_plan evaluated for a sequence of grids: the numbers come from numbl, not from a formula typed here. */
14+export function RefinementTable() {
15+ const runner = useRunner();
16+ const [rows, setRows] = useState<(FdPlan | null)[]>(GRIDS.map(() => null));
17+ useEffect(() => {
18+ let cancelled = false;
19+ (async () => {
20+ const out: (FdPlan | null)[] = [];
21+ for (const [nx, ny] of GRIDS) {
22+ const s = tutorialSpec();
23+ s.fd.nx = nx;
24+ s.fd.ny = ny;
25+ s.fd.duration = 0.01;
26+ const r = await runner.run({ bundle: buildPlanBundle(s, FD_METHOD)! });
27+ out.push(r.ok ? parseFdPlan(r.value) : null);
28+ if (!cancelled) setRows([...out, ...GRIDS.slice(out.length).map(() => null)]);
29+ }
30+ })();
31+ return () => {
32+ cancelled = true;
33+ };
34+ }, [runner]);
35+ return (
36+ <div className="embed">
37+ <div className="embed-title">Cost of 10 ms of simulated time on the default steel plate, from plate_fd_plan</div>
38+ <table>
39+ <thead>
40+ <tr>
41+ <th>grid</th>
42+ <th>dx [mm]</th>
43+ <th>dt_max [µs]</th>
44+ <th>steps</th>
45+ <th>cell-steps</th>
46+ </tr>
47+ </thead>
48+ <tbody>
49+ {GRIDS.map(([nx, ny], i) => {
50+ const p = rows[i];
51+ return (
52+ <tr key={nx}>
53+ <td>
54+ {nx} × {ny}
55+ </td>
56+ <td>{p ? (p.dx * 1000).toFixed(1) : "…"}</td>
57+ <td>{p && p.dt_max !== undefined ? (p.dt_max * 1e6).toPrecision(3) : "…"}</td>
58+ <td>{p ? p.nsteps.toLocaleString() : "…"}</td>
59+ <td>{p ? p.cell_steps.toExponential(2) : "…"}</td>
60+ </tr>
61+ );
62+ })}
63+ </tbody>
64+ </table>
65+ <div className="pl-small pl-muted" style={{ marginTop: 6 }}>
66+ Halving dx quarters dt and quadruples the node count: sixteen times the work per unit of simulated time.
67+ </div>
68+ </div>
69+ );
70+}
apps/tutorials/src/embeds/tutorialSpec.tsadded+13−0View file
@@ -0,0 +1,13 @@
1+import { defaultSpec, type ProblemSpec } from "@plate-lab/core";
2+
3+/** A small problem so every experiment on the page finishes in well under a second. */
4+export function tutorialSpec(): ProblemSpec {
5+ const s = defaultSpec();
6+ s.fd.nx = 31;
7+ s.fd.ny = 21;
8+ s.fd.duration = 0.0015;
9+ s.output.nframes = 24;
10+ s.output.grid = [31, 21];
11+ s.listener.patches = [12, 8];
12+ return s;
13+}
apps/tutorials/src/main.tsxadded+26−0View file
@@ -0,0 +1,26 @@
1+import { StrictMode } from "react";
2+import { createRoot } from "react-dom/client";
3+import { MDXProvider } from "@mdx-js/react";
4+import { RunnerProvider } from "@plate-lab/components";
5+import "@plate-lab/components/styles.css";
6+import "./styles.css";
7+import CflTutorial from "./tutorials/cfl.mdx";
8+
9+createRoot(document.getElementById("root")!).render(
10+ <StrictMode>
11+ <RunnerProvider>
12+ <MDXProvider>
13+ <div className="doc">
14+ <nav className="doc-nav">
15+ <strong>Plate Mechanics Tutorials</strong>
16+ <span className="pl-muted"> · </span>
17+ <a href="#stability">The stability limit of the explicit plate scheme</a>
18+ </nav>
19+ <article id="stability">
20+ <CflTutorial />
21+ </article>
22+ </div>
23+ </MDXProvider>
24+ </RunnerProvider>
25+ </StrictMode>,
26+);
apps/tutorials/src/mdx.d.tsadded+5−0View file
@@ -0,0 +1,5 @@
1+declare module "*.mdx" {
2+ import type { ComponentType } from "react";
3+ const Component: ComponentType<Record<string, unknown>>;
4+ export default Component;
5+}
apps/tutorials/src/styles.cssadded+15−0View file
@@ -0,0 +1,15 @@
1+* { box-sizing: border-box; }
2+body { margin: 0; font-family: var(--pl-font); color: var(--pl-text); background: #fff; font-size: 15px; line-height: 1.55; }
3+.doc { max-width: 820px; margin: 0 auto; padding: 24px 20px 80px; }
4+.doc-nav { font-size: 13px; padding-bottom: 12px; border-bottom: 1px solid var(--pl-border); margin-bottom: 24px; }
5+.doc h1 { font-size: 26px; margin: 0 0 6px; }
6+.doc h2 { font-size: 19px; margin: 32px 0 8px; }
7+.doc p { margin: 10px 0; }
8+.doc code { font-family: var(--pl-mono); font-size: 0.9em; background: var(--pl-panel); padding: 0 4px; border-radius: 3px; }
9+.embed { margin: 16px 0; padding: 12px; border: 1px solid var(--pl-border); border-radius: 8px; background: var(--pl-panel); font-size: 13px; }
10+.embed-title { font-weight: 600; margin-bottom: 8px; }
11+.embed .pl-code { height: 380px; }
12+.embed table { font-size: 13px; border-collapse: collapse; }
13+.embed td, .embed th { padding: 3px 10px; text-align: right; border-bottom: 1px solid var(--pl-border); }
14+.embed th:first-child, .embed td:first-child { text-align: left; }
15+.aside { font-size: 13px; color: var(--pl-muted); border-left: 3px solid var(--pl-border); padding-left: 10px; margin: 10px 0; }
apps/tutorials/src/tutorials/cfl.mdxadded+71−0View file
@@ -0,0 +1,71 @@
1+import { CflExperiment } from "../embeds/CflExperiment";
2+import { RefinementTable } from "../embeds/RefinementTable";
3+import { GoverningEquation, SchemeEquations } from "../embeds/Equations";
4+
5+# The stability limit of the explicit plate scheme
6+
7+An explicit time-stepping scheme computes the next state of the plate from the
8+current one with no equation solving. That makes each step cheap, but the step
9+cannot be chosen freely. Here we look at why a limit exists for the bending
10+plate, what happens when it is crossed, and what it costs to refine the grid.
11+Every number and picture on this page is produced by the same MATLAB source that
12+the lab runs, executed by numbl in this browser; open the source under any
13+experiment and change it.
14+
15+## The equation
16+
17+<GoverningEquation />
18+
19+The fourth spatial derivative is what sets this problem apart from the wave
20+equation. A bending wave of wavenumber k travels at a phase speed proportional to
21+k, so shorter waves are faster without bound. On a grid, the shortest wave that
22+can be represented has a wavelength of two cells, and its frequency grows as one
23+over the square of the spacing.
24+
25+## The scheme and its bound
26+
27+<SchemeEquations />
28+
29+The bound follows from asking that the scheme not amplify any wave the grid can
30+hold. For the stiffest one, at the largest eigenvalue of the discrete Laplacian,
31+the amplification factor has magnitude one exactly at the limit and exceeds one
32+beyond it. The dependence on dx squared, rather than dx, is the price of the
33+fourth derivative: halving the spacing quarters the largest stable step.
34+
35+## Cross the limit
36+
37+The slider sets the time step as a fraction of the bound. Below one, the run is
38+stable and the strike rings and decays. Above one, even slightly, the shortest
39+wave on the grid grows by a fixed factor every step. The growth is exponential,
40+so the run diverges within a few dozen steps and the solver stops itself,
41+keeping the frames up to that point.
42+
43+<CflExperiment />
44+
45+Note that instability is not a gradual loss of accuracy. At a step ratio of 1.03
46+the scheme is only three percent past the bound, yet the result is useless
47+within a millisecond. In contrast, the modal method in the lab has no step
48+limit: each mode is integrated with an exact update, and the only time-related
49+approximation is the sampling of the force.
50+
51+## What refinement costs
52+
53+Because the stable step scales with the square of the spacing, refining the
54+grid is expensive in a way that is easy to underestimate. The table below is
55+the output of the planning function for four grids on the same plate.
56+
57+<RefinementTable />
58+
59+The lab shows an estimate of this cost before each finite-difference run. For a
60+long simulated time on a fine grid the modal method is the practical choice on
61+a simply supported plate; the finite-difference method earns its cost where no
62+closed-form modes exist, such as clamped edges.
63+
64+## Going further
65+
66+The limit derived above assumes the frequency-dependent loss term is small. The
67+planning function includes it exactly, so setting a large sigma1 in the lab
68+lowers the stable step further; the source of `plate_fd_plan.m` shows the
69+formula. A stiffer scheme, implicit in time, would remove the limit at the cost
70+of a linear solve per step; that is a natural extension of the method registry
71+rather than a change to what exists.
apps/tutorials/tsconfig.jsonadded+9−0View file
@@ -0,0 +1,9 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ "noEmit": true
5+ },
6+ "include": [
7+ "src"
8+ ]
9+}
apps/tutorials/vite.config.tsadded+10−0View file
@@ -0,0 +1,10 @@
1+import { defineConfig } from "vite";
2+import react from "@vitejs/plugin-react";
3+import { numblDefine } from "../../vite.shared";
4+import mdx from "@mdx-js/rollup";
5+
6+export default defineConfig({
7+ plugins: [{ enforce: "pre", ...mdx({ providerImportSource: "@mdx-js/react" }) }, react()],
8+ define: numblDefine(),
9+ worker: { format: "es" },
10+});
package-lock.jsonadded+4860−0View file
This diff is 4,865 lines long and is not shown.
package.jsonadded+29−0View file
@@ -0,0 +1,29 @@
1+{
2+ "name": "plate-mechanics-lab",
3+ "private": true,
4+ "version": "0.1.0",
5+ "description": "Interactive thin-plate mechanics lab: Kirchhoff-Love plates solved by numbl-executed MATLAB source in the browser",
6+ "type": "module",
7+ "workspaces": [
8+ "packages/*",
9+ "apps/*"
10+ ],
11+ "scripts": {
12+ "dev": "npm run dev -w apps/lab",
13+ "dev:tutorials": "npm run dev -w apps/tutorials",
14+ "build": "npm run build -w packages/core -w packages/components -w apps/lab -w apps/tutorials --if-present",
15+ "typecheck": "tsc -p tsconfig.json",
16+ "test": "vitest run",
17+ "format": "prettier --write ."
18+ },
19+ "devDependencies": {
20+ "@types/node": "^24.10.1",
21+ "@types/react": "^19.2.5",
22+ "@types/react-dom": "^19.2.3",
23+ "@vitejs/plugin-react": "^5.1.1",
24+ "prettier": "^3.8.1",
25+ "typescript": "~5.9.3",
26+ "vite": "^7.2.4",
27+ "vitest": "^4.0.18"
28+ }
29+}
packages/components/package.jsonadded+30−0View file
@@ -0,0 +1,30 @@
1+{
2+ "name": "@plate-lab/components",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "description": "Composable React components over @plate-lab/core: code view, parameter panel, field visualization, plots, equations, run status, audio.",
7+ "exports": {
8+ ".": "./src/index.ts",
9+ "./styles.css": "./src/styles.css"
10+ },
11+ "scripts": {
12+ "typecheck": "tsc -p tsconfig.json --noEmit"
13+ },
14+ "dependencies": {
15+ "@plate-lab/core": "*",
16+ "@codemirror/language": "^6.11.0",
17+ "@codemirror/legacy-modes": "^6.5.0",
18+ "codemirror": "^6.0.1",
19+ "katex": "^0.16.22",
20+ "@codemirror/state": "^6.5.2",
21+ "@codemirror/view": "^6.38.1"
22+ },
23+ "peerDependencies": {
24+ "react": ">=18",
25+ "react-dom": ">=18"
26+ },
27+ "devDependencies": {
28+ "@types/katex": "^0.16.7"
29+ }
30+}
packages/components/src/audio/AudioPlayer.tsxadded+53−0View file
@@ -0,0 +1,53 @@
1+import { useEffect, useMemo, useRef, useState } from "react";
2+import { prepareAudio, toAudioBuffer } from "@plate-lab/core";
3+
4+export interface AudioPlayerProps {
5+ /** Pressure signal [Pa] */
6+ p: Float64Array;
7+ fs: number;
8+ label?: string;
9+}
10+
11+/**
12+ * Plays the radiated-pressure estimate once, exactly as long as it is. Playback
13+ * level is normalized; the displayed peak is not. A clip of a few milliseconds
14+ * sounds like a click, and is labelled as such rather than repeated.
15+ */
16+export function AudioPlayer({ p, fs, label }: AudioPlayerProps) {
17+ const prep = useMemo(() => prepareAudio(p, fs), [p, fs]);
18+ const ctxRef = useRef<AudioContext | null>(null);
19+ const srcRef = useRef<AudioBufferSourceNode | null>(null);
20+ const [playing, setPlaying] = useState(false);
21+ useEffect(() => () => stop(), []);
22+
23+ const stop = () => {
24+ srcRef.current?.stop();
25+ srcRef.current = null;
26+ setPlaying(false);
27+ };
28+ const play = () => {
29+ if (!ctxRef.current) ctxRef.current = new AudioContext();
30+ const ctx = ctxRef.current;
31+ void ctx.resume();
32+ stop();
33+ const src = ctx.createBufferSource();
34+ src.buffer = toAudioBuffer(ctx, prep);
35+ src.connect(ctx.destination);
36+ src.start();
37+ src.onended = () => setPlaying(false);
38+ srcRef.current = src;
39+ setPlaying(true);
40+ };
41+ const short = prep.durationS < 0.1;
42+ return (
43+ <div className="pl-row pl-small">
44+ <button className="pl-btn" onClick={playing ? stop : play} disabled={p.length < 2}>
45+ {playing ? "Stop" : `Play${label ? ` ${label}` : ""}`}
46+ </button>
47+ <span className="pl-muted">
48+ {(prep.durationS * 1000).toFixed(1)} ms at {fs} Hz, peak {prep.peakPa.toExponential(2)} Pa; normalized for playback, played once
49+ {short ? ". This excerpt is too short to hear as a note; lengthen the simulated window for a longer clip" : ""}
50+ </span>
51+ </div>
52+ );
53+}
packages/components/src/code/CodeView.tsxadded+150−0View file
@@ -0,0 +1,150 @@
1+import { useEffect, useMemo, useRef, useState } from "react";
2+import { EditorView, basicSetup } from "codemirror";
3+import { EditorState, Compartment } from "@codemirror/state";
4+import { StreamLanguage } from "@codemirror/language";
5+import { octave } from "@codemirror/legacy-modes/mode/octave";
6+import type { SourceFile } from "@plate-lab/core";
7+
8+export interface CodeViewProps {
9+ /** The generated sources */
10+ files: SourceFile[];
11+ /** User edits by file name; a file absent here shows its generated text */
12+ edits: Record<string, string>;
13+ onEditsChange: (edits: Record<string, string>) => void;
14+ /** Tab order; files not listed follow in their original order */
15+ order?: string[];
16+ /** One-line descriptions shown under the tabs */
17+ summaries?: Record<string, string>;
18+ readOnly?: boolean;
19+ /** Called with the active file name */
20+ onActiveChange?: (name: string) => void;
21+ initialFile?: string;
22+ height?: number | string;
23+}
24+
25+/**
26+ * Tabbed source view of exactly the `.m` files a run executes. Editing a file
27+ * records an edit; the run then uses the edited text, and a modified tab shows
28+ * an asterisk with a way back to the generated version.
29+ */
30+export function CodeView(props: CodeViewProps) {
31+ const { files, edits, onEditsChange, order, summaries, readOnly, onActiveChange, initialFile, height } = props;
32+ const ordered = useMemo(() => {
33+ const byName = new Map(files.map((f) => [f.name, f]));
34+ const out: SourceFile[] = [];
35+ for (const n of order ?? []) if (byName.has(n)) out.push(byName.get(n)!);
36+ for (const f of files) if (!out.includes(f)) out.push(f);
37+ return out;
38+ }, [files, order]);
39+
40+ const [active, setActive] = useState(initialFile ?? ordered[0]?.name ?? "");
41+ useEffect(() => {
42+ if (!ordered.some((f) => f.name === active) && ordered[0]) setActive(ordered[0].name);
43+ }, [ordered, active]);
44+ useEffect(() => onActiveChange?.(active), [active, onActiveChange]);
45+
46+ const hostRef = useRef<HTMLDivElement>(null);
47+ const viewRef = useRef<EditorView | null>(null);
48+ const editsRef = useRef(edits);
49+ editsRef.current = edits;
50+ const filesRef = useRef(files);
51+ filesRef.current = files;
52+ const activeRef = useRef(active);
53+ activeRef.current = active;
54+ const onEditsRef = useRef(onEditsChange);
55+ onEditsRef.current = onEditsChange;
56+ const readOnlyCompartment = useMemo(() => new Compartment(), []);
57+
58+ // One editor for the lifetime of the component
59+ useEffect(() => {
60+ if (!hostRef.current) return;
61+ const view = new EditorView({
62+ parent: hostRef.current,
63+ state: EditorState.create({
64+ doc: "",
65+ extensions: [
66+ basicSetup,
67+ StreamLanguage.define(octave),
68+ readOnlyCompartment.of(EditorState.readOnly.of(!!readOnly)),
69+ EditorView.updateListener.of((u) => {
70+ if (!u.docChanged) return;
71+ const name = activeRef.current;
72+ const text = u.state.doc.toString();
73+ const original = filesRef.current.find((f) => f.name === name)?.source;
74+ const next = { ...editsRef.current };
75+ if (text === original) delete next[name];
76+ else next[name] = text;
77+ onEditsRef.current(next);
78+ }),
79+ ],
80+ }),
81+ });
82+ viewRef.current = view;
83+ return () => {
84+ view.destroy();
85+ viewRef.current = null;
86+ };
87+ // eslint-disable-next-line react-hooks/exhaustive-deps
88+ }, []);
89+
90+ useEffect(() => {
91+ viewRef.current?.dispatch({ effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(!!readOnly)) });
92+ }, [readOnly, readOnlyCompartment]);
93+
94+ // Load the active file's text (edited or generated) into the editor whenever it changes externally
95+ const shown = edits[active] ?? files.find((f) => f.name === active)?.source ?? "";
96+ useEffect(() => {
97+ const view = viewRef.current;
98+ if (!view) return;
99+ const current = view.state.doc.toString();
100+ if (current !== shown) {
101+ view.dispatch({ changes: { from: 0, to: current.length, insert: shown } });
102+ }
103+ }, [shown, active]);
104+
105+ const modified = active in edits;
106+ const restore = () => {
107+ const next = { ...edits };
108+ delete next[active];
109+ onEditsChange(next);
110+ };
111+ const restoreAll = () => onEditsChange({});
112+ const nModified = Object.keys(edits).length;
113+
114+ return (
115+ <div className="pl-code" style={{ height }}>
116+ <div className="pl-code-tabs" role="tablist">
117+ {ordered.map((f) => (
118+ <button
119+ key={f.name}
120+ role="tab"
121+ aria-selected={f.name === active}
122+ className={`pl-code-tab${f.name === active ? " pl-active" : ""}${f.name in edits ? " pl-modified" : ""}`}
123+ onClick={() => setActive(f.name)}
124+ title={summaries?.[f.name]}
125+ >
126+ {f.name}
127+ </button>
128+ ))}
129+ </div>
130+ <div className="pl-code-toolbar">
131+ <span>{summaries?.[active] ?? ""}</span>
132+ <span style={{ flex: 1 }} />
133+ {modified && (
134+ <>
135+ <span className="pl-badge pl-badge-warn">edited</span>
136+ <button className="pl-btn" onClick={restore}>
137+ Restore this file
138+ </button>
139+ </>
140+ )}
141+ {nModified > 1 && (
142+ <button className="pl-btn" onClick={restoreAll}>
143+ Restore all ({nModified})
144+ </button>
145+ )}
146+ </div>
147+ <div className="pl-code-editor" ref={hostRef} />
148+ </div>
149+ );
150+}
packages/components/src/compare/CompareView.tsxadded+91−0View file
@@ -0,0 +1,91 @@
1+import type { CompareResult } from "@plate-lab/core";
2+import { LinePlot } from "../plots/LinePlot";
3+import { useSharedAxis } from "../plots/useSharedAxis";
4+
5+export interface CompareViewProps {
6+ cmp: CompareResult;
7+ labelA?: string;
8+ labelB?: string;
9+}
10+
11+/** Side-by-side metrics and overlaid probe series for two solutions of one problem. */
12+export function CompareView({ cmp, labelA = "modal", labelB = "finite differences" }: CompareViewProps) {
13+ const pct = (x: number) => `${(100 * x).toFixed(2)} %`;
14+ const axis = useSharedAxis();
15+ return (
16+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
17+ <table className="pl-metrics">
18+ <thead>
19+ <tr>
20+ <th>Quantity</th>
21+ <th>{labelA}</th>
22+ <th>{labelB}</th>
23+ <th>relative L2 difference</th>
24+ </tr>
25+ </thead>
26+ <tbody>
27+ <tr>
28+ <td>Peak displacement at probe</td>
29+ <td>{(cmp.w_peak_a * 1e6).toPrecision(4)} µm</td>
30+ <td>{(cmp.w_peak_b * 1e6).toPrecision(4)} µm</td>
31+ <td>{pct(cmp.w_rel_l2)}</td>
32+ </tr>
33+ <tr>
34+ <td>Peak von Mises stress at probe</td>
35+ <td>{(cmp.vm_peak_a * 1e-6).toPrecision(4)} MPa</td>
36+ <td>{(cmp.vm_peak_b * 1e-6).toPrecision(4)} MPa</td>
37+ <td>{pct(cmp.vm_rel_l2)}</td>
38+ </tr>
39+ <tr>
40+ <td>Strongest spectral peak</td>
41+ <td>{cmp.f_peak_a.toFixed(0)} Hz</td>
42+ <td>{cmp.f_peak_b.toFixed(0)} Hz</td>
43+ <td>–</td>
44+ </tr>
45+ </tbody>
46+ </table>
47+ <div className="pl-small pl-muted">
48+ Stress is a second derivative of displacement, so its difference is expected to exceed the displacement difference. The window is the shorter of the two runs.
49+ </div>
50+ <LinePlot
51+ title="Displacement at the probe"
52+ series={[
53+ { x: cmp.t, y: cmp.w_a, label: labelA },
54+ { x: cmp.t, y: cmp.w_b, label: labelB, dash: [4, 3] },
55+ ]}
56+ xScale={1000}
57+ yScale={1e6}
58+ xLabel="time [ms]"
59+ yLabel="w [µm]"
60+ yUnit="µm"
61+ height={180}
62+ {...axis}
63+ />
64+ <LinePlot
65+ title="Von Mises stress at the probe"
66+ series={[
67+ { x: cmp.t, y: cmp.vm_a, label: labelA },
68+ { x: cmp.t, y: cmp.vm_b, label: labelB, dash: [4, 3] },
69+ ]}
70+ xScale={1000}
71+ yScale={1e-6}
72+ xLabel="time [ms]"
73+ yLabel="σ_vM [MPa]"
74+ yUnit="MPa"
75+ height={180}
76+ {...axis}
77+ />
78+ {cmp.frames_rel_l2.length > 0 && (
79+ <LinePlot
80+ title="Relative L2 difference of the displacement field, frame by frame"
81+ series={[{ x: cmp.frames_t, y: cmp.frames_rel_l2, label: "‖w_a − w_b‖ / ‖w_a‖" }]}
82+ xScale={1000}
83+ xLabel="time [ms]"
84+ yLabel="relative difference"
85+ height={150}
86+ {...axis}
87+ />
88+ )}
89+ </div>
90+ );
91+}
packages/components/src/field/Colorbar.tsxadded+41−0View file
@@ -0,0 +1,41 @@
1+import { useEffect, useRef } from "react";
2+import { colormapTable, getColormap, type Range } from "@plate-lab/core";
3+
4+export function Colorbar({ range, colormap, unit, scale = 1, height = 200 }: { range: Range; colormap: string; unit?: string; scale?: number; height?: number }) {
5+ const ref = useRef<HTMLCanvasElement>(null);
6+ useEffect(() => {
7+ const c = ref.current;
8+ if (!c) return;
9+ const ctx = c.getContext("2d");
10+ if (!ctx) return;
11+ const table = colormapTable(getColormap(colormap));
12+ const h = 256;
13+ c.width = 1;
14+ c.height = h;
15+ const img = ctx.createImageData(1, h);
16+ for (let k = 0; k < h; k++) {
17+ const t = 255 - k;
18+ img.data[4 * k] = table[3 * t];
19+ img.data[4 * k + 1] = table[3 * t + 1];
20+ img.data[4 * k + 2] = table[3 * t + 2];
21+ img.data[4 * k + 3] = 255;
22+ }
23+ ctx.putImageData(img, 0, 0);
24+ }, [colormap]);
25+ return (
26+ <div className="pl-colorbar" style={{ height }}>
27+ <span>{fmt(range.max * scale)}</span>
28+ <canvas ref={ref} />
29+ <span>
30+ {fmt(range.min * scale)} {unit}
31+ </span>
32+ </div>
33+ );
34+}
35+
36+export function fmt(x: number): string {
37+ if (x === 0) return "0";
38+ const a = Math.abs(x);
39+ if (a >= 1e4 || a < 1e-2) return x.toExponential(2);
40+ return x.toPrecision(3);
41+}
packages/components/src/field/FieldView.tsxadded+140−0View file
@@ -0,0 +1,140 @@
1+import { useEffect, useMemo, useRef } from "react";
2+import {
3+ frameOf,
4+ getColormap,
5+ rangeOf,
6+ rasterizeField,
7+ symmetricRange,
8+ type Field2D,
9+ type FieldFrames,
10+ type Range,
11+} from "@plate-lab/core";
12+import { Colorbar, fmt } from "./Colorbar";
13+
14+export interface FieldMarker {
15+ x: number;
16+ y: number;
17+ label: string;
18+ color?: string;
19+}
20+
21+export interface FieldViewProps {
22+ /** Either a single field or a frame stack with an index */
23+ field?: Field2D;
24+ frames?: FieldFrames;
25+ frameIndex?: number;
26+ /** Physical extents [m] */
27+ Lx: number;
28+ Ly: number;
29+ title?: string;
30+ unit?: string;
31+ /** Display multiplier applied to values in labels (e.g. 1e-6 for MPa) */
32+ scale?: number;
33+ colormap?: string;
34+ /** "frame": rescale per frame; "global": one range over all frames; or a fixed range */
35+ range?: "frame" | "global" | Range;
36+ /** Force a symmetric range about zero (for signed fields) */
37+ symmetric?: boolean;
38+ markers?: FieldMarker[];
39+ /** Canvas width in CSS pixels; height follows the aspect ratio */
40+ width?: number;
41+ caption?: string;
42+}
43+
44+/**
45+ * Paints a field on the plate outline. Values pass straight from the solver
46+ * through a colormap; nothing here changes them.
47+ */
48+export function FieldView(props: FieldViewProps) {
49+ const { frames, frameIndex = 0, Lx, Ly, title, unit, scale = 1, colormap, symmetric, markers = [], width = 420, caption } = props;
50+ const field: Field2D | undefined = props.field ?? (frames ? frameOf(frames, Math.min(frameIndex, frames.nf - 1)) : undefined);
51+ const cmId = colormap ?? (symmetric ? "bwr" : "viridis");
52+
53+ const globalRange = useMemo(() => {
54+ const r = frames ? rangeOf(frames.data) : field ? rangeOf(field.data) : { min: 0, max: 1 };
55+ return symmetric ? symmetricRange(r) : r;
56+ }, [frames, field, symmetric]);
57+
58+ const range: Range = useMemo(() => {
59+ const mode = props.range ?? "global";
60+ if (typeof mode === "object") return mode;
61+ if (mode === "global" || !field) return globalRange;
62+ const r = rangeOf(field.data);
63+ return symmetric ? symmetricRange(r) : r;
64+ }, [props.range, field, globalRange, symmetric]);
65+
66+ const canvasRef = useRef<HTMLCanvasElement>(null);
67+ const offRef = useRef<HTMLCanvasElement | null>(null);
68+ const pixRef = useRef<Uint8ClampedArray | undefined>(undefined);
69+
70+ const height = Math.max(60, Math.round((width * Ly) / Lx));
71+
72+ useEffect(() => {
73+ const canvas = canvasRef.current;
74+ if (!canvas || !field) return;
75+ const ctx = canvas.getContext("2d");
76+ if (!ctx) return;
77+ const dpr = Math.min(2, window.devicePixelRatio || 1);
78+ canvas.width = Math.round(width * dpr);
79+ canvas.height = Math.round(height * dpr);
80+ // Rasterize at grid resolution, then scale with smoothing onto the plate outline
81+ if (!offRef.current) offRef.current = document.createElement("canvas");
82+ const off = offRef.current;
83+ off.width = field.nx;
84+ off.height = field.ny;
85+ const pix = rasterizeField(field, range, getColormap(cmId), pixRef.current);
86+ pixRef.current = pix;
87+ const octx = off.getContext("2d");
88+ if (!octx) return;
89+ octx.putImageData(new ImageData(pix as Uint8ClampedArray<ArrayBuffer>, field.nx, field.ny), 0, 0);
90+ ctx.save();
91+ ctx.scale(dpr, dpr);
92+ ctx.clearRect(0, 0, width, height);
93+ ctx.imageSmoothingEnabled = true;
94+ // Grid nodes sit at cell centres of the image; map node (0..nx-1) to the full extent
95+ const sx = width / (field.nx - 1);
96+ const sy = height / (field.ny - 1);
97+ ctx.drawImage(off, -sx / 2, -sy / 2, width + sx, height + sy);
98+ ctx.strokeStyle = "#333";
99+ ctx.lineWidth = 1;
100+ ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
101+ for (const m of markers) {
102+ const px = (m.x / Lx) * width;
103+ const py = height - (m.y / Ly) * height;
104+ ctx.beginPath();
105+ ctx.arc(px, py, 5, 0, 2 * Math.PI);
106+ ctx.fillStyle = "rgba(255,255,255,0.85)";
107+ ctx.fill();
108+ ctx.strokeStyle = m.color ?? "#000";
109+ ctx.lineWidth = 1.5;
110+ ctx.stroke();
111+ ctx.font = "11px system-ui, sans-serif";
112+ ctx.fillStyle = "#000";
113+ ctx.strokeStyle = "rgba(255,255,255,0.9)";
114+ ctx.lineWidth = 3;
115+ ctx.strokeText(m.label, px + 8, py - 6);
116+ ctx.fillText(m.label, px + 8, py - 6);
117+ }
118+ ctx.restore();
119+ }, [field, range, cmId, width, height, markers, Lx, Ly]);
120+
121+ const peak = field ? rangeOf(field.data) : null;
122+
123+ return (
124+ <div className="pl-fieldview">
125+ <div className="pl-fieldview-head">
126+ <strong>{title}</strong>
127+ {peak && (
128+ <span className="pl-muted pl-mono">
129+ {fmt(peak.min * scale)} to {fmt(peak.max * scale)} {unit}
130+ </span>
131+ )}
132+ </div>
133+ <div className="pl-fieldview-body">
134+ <canvas ref={canvasRef} style={{ width, height, aspectRatio: `${Lx} / ${Ly}` }} aria-label={title} />
135+ <Colorbar range={range} colormap={cmId} unit={unit} scale={scale} height={height} />
136+ </div>
137+ {caption && <div className="pl-small pl-muted">{caption}</div>}
138+ </div>
139+ );
140+}
packages/components/src/field/FramePlayer.tsxadded+53−0View file
@@ -0,0 +1,53 @@
1+import { useCallback, useEffect, useRef, useState } from "react";
2+
3+export interface FramePlayerState {
4+ index: number;
5+ setIndex: (i: number) => void;
6+ playing: boolean;
7+ setPlaying: (p: boolean) => void;
8+}
9+
10+/** Advance a frame index at a fixed rate while playing. */
11+export function useFramePlayer(nf: number, fps = 20, autoplay = false): FramePlayerState {
12+ const [index, setIndexState] = useState(0);
13+ const [playing, setPlaying] = useState(autoplay);
14+ const timer = useRef<number | null>(null);
15+ useEffect(() => {
16+ if (index >= nf) setIndexState(0);
17+ }, [nf, index]);
18+ useEffect(() => {
19+ if (!playing || nf <= 1) return;
20+ timer.current = window.setInterval(() => setIndexState((i) => (i + 1) % nf), 1000 / fps);
21+ return () => {
22+ if (timer.current !== null) window.clearInterval(timer.current);
23+ };
24+ }, [playing, nf, fps]);
25+ const setIndex = useCallback((i: number) => setIndexState(Math.max(0, Math.min(nf - 1, i))), [nf]);
26+ return { index, setIndex, playing, setPlaying };
27+}
28+
29+export interface FramePlayerProps extends FramePlayerState {
30+ times: Float64Array;
31+ /** Show time in these units */
32+ timeScale?: number;
33+ timeUnit?: string;
34+}
35+
36+export function FramePlayer({ index, setIndex, playing, setPlaying, times, timeScale = 1000, timeUnit = "ms" }: FramePlayerProps) {
37+ const nf = times.length;
38+ const t = times[Math.min(index, nf - 1)] ?? 0;
39+ return (
40+ <div className="pl-player">
41+ <button className="pl-btn" onClick={() => setPlaying(!playing)} disabled={nf <= 1} aria-label={playing ? "pause" : "play"}>
42+ {playing ? "Pause" : "Play"}
43+ </button>
44+ <input type="range" min={0} max={Math.max(0, nf - 1)} value={Math.min(index, nf - 1)} onChange={(e) => setIndex(Number(e.target.value))} aria-label="frame" />
45+ <span className="pl-mono">
46+ {(t * timeScale).toFixed(2)} {timeUnit}
47+ </span>
48+ <span className="pl-muted pl-small">
49+ frame {Math.min(index, nf - 1) + 1}/{nf}
50+ </span>
51+ </div>
52+ );
53+}
packages/components/src/hooks/RunnerContext.tsxadded+28−0View file
@@ -0,0 +1,28 @@
1+import { createContext, useContext, useEffect, useRef, type ReactNode } from "react";
2+import { WorkerRunner, type Runner } from "@plate-lab/core";
3+
4+const RunnerContext = createContext<Runner | null>(null);
5+
6+/**
7+ * Provides one numbl runner to a tree of components. By default a WorkerRunner
8+ * is created lazily; pass `runner` to share or substitute one.
9+ */
10+export function RunnerProvider({ children, runner }: { children: ReactNode; runner?: Runner }) {
11+ const ref = useRef<Runner | null>(null);
12+ if (!ref.current) ref.current = runner ?? new WorkerRunner();
13+ useEffect(() => {
14+ const r = ref.current;
15+ return () => {
16+ // Dispose terminates the worker; the runner recreates one on the next run,
17+ // so React StrictMode's mount-unmount-mount cycle is harmless.
18+ r?.dispose();
19+ };
20+ }, []);
21+ return <RunnerContext.Provider value={ref.current}>{children}</RunnerContext.Provider>;
22+}
23+
24+export function useRunner(): Runner {
25+ const r = useContext(RunnerContext);
26+ if (!r) throw new Error("useRunner must be used inside a RunnerProvider");
27+ return r;
28+}
packages/components/src/hooks/useSolve.tsadded+82−0View file
@@ -0,0 +1,82 @@
1+import { useCallback, useRef, useState } from "react";
2+import type { PlainValue, RunFailure, RunRequest, RunResult, RunSuccess } from "@plate-lab/core";
3+import { useRunner } from "./RunnerContext";
4+
5+export type SolveState<T> =
6+ | { status: "idle" }
7+ | { status: "running"; startedAt: number; output: string[] }
8+ | { status: "done"; run: RunSuccess; value: T; output: string[] }
9+ | { status: "error"; run: RunFailure; output: string[] };
10+
11+export interface Solve<T> {
12+ state: SolveState<T>;
13+ /** Most recent successful value, kept while a new run is in progress */
14+ last: T | null;
15+ run: (req: RunRequest) => Promise<RunResult | undefined>;
16+ cancel: () => void;
17+ /** Forget the last value and go back to idle, so nothing stale is displayed */
18+ reset: () => void;
19+}
20+
21+/**
22+ * Drive one run at a time through the shared runner. `parse` types the raw
23+ * result; a parse failure is reported like any other error so a script that
24+ * returns the wrong shape never looks like a blank screen.
25+ */
26+export function useSolve<T>(parse: (v: PlainValue) => T): Solve<T> {
27+ const runner = useRunner();
28+ const [state, setState] = useState<SolveState<T>>({ status: "idle" });
29+ const [last, setLast] = useState<T | null>(null);
30+ const seq = useRef(0);
31+
32+ const run = useCallback(
33+ async (req: RunRequest) => {
34+ const id = ++seq.current;
35+ const output: string[] = [];
36+ setState({ status: "running", startedAt: performance.now(), output: [] });
37+ const res = await runner.run(req, (text) => {
38+ output.push(text);
39+ if (seq.current === id)
40+ setState((s) => (s.status === "running" ? { ...s, output: [...output] } : s));
41+ });
42+ if (seq.current !== id) return undefined;
43+ if (res.ok) {
44+ try {
45+ const value = parse(res.value);
46+ setLast(value);
47+ setState({ status: "done", run: res, value, output: res.output });
48+ } catch (e) {
49+ setState({
50+ status: "error",
51+ run: {
52+ ...res,
53+ ok: false,
54+ error: `the script ran, but its result has an unexpected shape: ${(e as Error).message}`,
55+ },
56+ output: res.output,
57+ });
58+ }
59+ } else {
60+ setState({ status: "error", run: res, output: res.output });
61+ }
62+ return res;
63+ },
64+ [runner, parse],
65+ );
66+
67+ const cancel = useCallback(() => {
68+ seq.current++;
69+ runner.cancel();
70+ setState({ status: "idle" });
71+ }, [runner]);
72+
73+ // Local state only: nothing is terminated, so the worker (and the numbl module it
74+ // has loaded) survives. Use `cancel` to stop a run that is actually in flight.
75+ const reset = useCallback(() => {
76+ seq.current++;
77+ setLast(null);
78+ setState({ status: "idle" });
79+ }, []);
80+
81+ return { state, last, run, cancel, reset };
82+}
packages/components/src/index.tsadded+16−0View file
@@ -0,0 +1,16 @@
1+export { RunnerProvider, useRunner } from "./hooks/RunnerContext";
2+export { useSolve, type Solve, type SolveState } from "./hooks/useSolve";
3+export { CodeView, type CodeViewProps } from "./code/CodeView";
4+export { NumberField, type NumberFieldProps } from "./params/NumberField";
5+export { CflSlider, type CflSliderProps } from "./params/CflSlider";
6+export { ParameterPanel, ALL_SECTIONS, type ParameterPanelProps, type ParameterSection } from "./params/ParameterPanel";
7+export { FieldView, type FieldViewProps, type FieldMarker } from "./field/FieldView";
8+export { Colorbar } from "./field/Colorbar";
9+export { FramePlayer, useFramePlayer, type FramePlayerProps, type FramePlayerState } from "./field/FramePlayer";
10+export { LinePlot, type LinePlotProps, type Series, type XRange } from "./plots/LinePlot";
11+export { useSharedAxis } from "./plots/useSharedAxis";
12+export { EquationsView } from "./status/EquationsView";
13+export { RunStatus, fmtMs, fmtTime, type RunStatusProps } from "./status/RunStatus";
14+export { ValidityNotes } from "./status/ValidityNotes";
15+export { AudioPlayer, type AudioPlayerProps } from "./audio/AudioPlayer";
16+export { CompareView, type CompareViewProps } from "./compare/CompareView";
packages/components/src/params/CflSlider.tsxadded+45−0View file
@@ -0,0 +1,45 @@
1+export interface CflSliderProps {
2+ value: number;
3+ onChange: (v: number) => void;
4+ /** Largest stable step, when known, to show the resulting dt */
5+ dtMax?: number;
6+ min?: number;
7+ max?: number;
8+ disabled?: boolean;
9+}
10+
11+/**
12+ * The time step as a fraction of the stability limit. Past 1 the explicit
13+ * scheme amplifies its shortest waves every step, and the run blows up within
14+ * a few dozen steps; the slider colours that region so it is not a surprise.
15+ */
16+export function CflSlider({ value, onChange, dtMax, min = 0.5, max = 1.2, disabled }: CflSliderProps) {
17+ const unstable = value > 1;
18+ const pct = ((1 - min) / (max - min)) * 100;
19+ return (
20+ <div className={disabled ? "pl-disabled" : undefined}>
21+ <div className="pl-slider">
22+ <input
23+ type="range"
24+ min={min}
25+ max={max}
26+ step={0.01}
27+ value={value}
28+ disabled={disabled}
29+ onChange={(e) => onChange(Number(e.target.value))}
30+ style={{
31+ background: `linear-gradient(to right, #cfe0f5 0%, #cfe0f5 ${pct}%, #f5c6c6 ${pct}%, #f5c6c6 100%)`,
32+ }}
33+ aria-label="time step as a fraction of the stability limit"
34+ />
35+ <span className="pl-mono" style={{ color: unstable ? "var(--pl-error)" : undefined }}>
36+ {value.toFixed(2)} dt<sub>max</sub>
37+ </span>
38+ </div>
39+ <div className="pl-small pl-muted">
40+ {dtMax !== undefined ? <>dt = {(value * dtMax * 1e6).toPrecision(3)} µs (limit {(dtMax * 1e6).toPrecision(3)} µs). </> : null}
41+ {unstable ? <span style={{ color: "var(--pl-error)" }}>Above the limit: the run will diverge.</span> : "Stable below 1."}
42+ </div>
43+ </div>
44+ );
45+}
packages/components/src/params/NumberField.tsxadded+57−0View file
@@ -0,0 +1,57 @@
1+import { useEffect, useState } from "react";
2+
3+export interface NumberFieldProps {
4+ label: string;
5+ value: number;
6+ onChange: (v: number) => void;
7+ /** Display = value * scale (e.g. 1000 to show metres as millimetres) */
8+ scale?: number;
9+ unit?: string;
10+ step?: number;
11+ min?: number;
12+ max?: number;
13+ integer?: boolean;
14+ invalid?: boolean;
15+ disabled?: boolean;
16+ title?: string;
17+}
18+
19+/** A numeric input that tolerates partial typing and commits parsed values. */
20+export function NumberField(props: NumberFieldProps) {
21+ const { label, value, onChange, scale = 1, unit, step, min, max, integer, invalid, disabled, title } = props;
22+ const shown = formatShown(value * scale);
23+ const [text, setText] = useState(shown);
24+ useEffect(() => {
25+ // Sync from outside unless the text already means the same number
26+ if (Number(text) * 1 !== value * scale) setText(shown);
27+ // eslint-disable-next-line react-hooks/exhaustive-deps
28+ }, [value, scale]);
29+ return (
30+ <div className={`pl-field${disabled ? " pl-disabled" : ""}`} title={title}>
31+ <label>{label}</label>
32+ <input
33+ type="number"
34+ className={invalid ? "pl-invalid" : undefined}
35+ value={text}
36+ step={step ?? (integer ? 1 : "any")}
37+ min={min}
38+ max={max}
39+ disabled={disabled}
40+ onChange={(e) => {
41+ setText(e.target.value);
42+ const n = Number(e.target.value);
43+ if (e.target.value.trim() === "" || !Number.isFinite(n)) return;
44+ onChange(integer ? Math.round(n) : n / scale);
45+ }}
46+ onBlur={() => setText(shown)}
47+ />
48+ <span className="pl-unit">{unit ?? ""}</span>
49+ </div>
50+ );
51+}
52+
53+function formatShown(x: number): string {
54+ if (!Number.isFinite(x)) return "";
55+ const r = Number(x.toPrecision(10));
56+ return String(r);
57+}
packages/components/src/params/ParameterPanel.tsxadded+257−0View file
@@ -0,0 +1,257 @@
1+import type { ReactNode } from "react";
2+import {
3+ MATERIAL_PRESETS,
4+ PLATE_PRESETS,
5+ type BoundaryCondition,
6+ type Excitation,
7+ type MethodDefinition,
8+ type ProblemSpec,
9+ type ValidationIssue,
10+} from "@plate-lab/core";
11+import { NumberField } from "./NumberField";
12+import { CflSlider } from "./CflSlider";
13+
14+export type ParameterSection =
15+ | "plate"
16+ | "material"
17+ | "edges"
18+ | "damping"
19+ | "excitation"
20+ | "probe"
21+ | "listener"
22+ | "output"
23+ | "modal"
24+ | "fd"
25+ | "implicit";
26+
27+export const ALL_SECTIONS: ParameterSection[] = [
28+ "plate",
29+ "material",
30+ "edges",
31+ "damping",
32+ "excitation",
33+ "probe",
34+ "listener",
35+ "output",
36+ "modal",
37+ "fd",
38+ "implicit",
39+];
40+
41+export interface ParameterPanelProps {
42+ spec: ProblemSpec;
43+ onChange: (spec: ProblemSpec) => void;
44+ /** Sections to show; default all */
45+ sections?: ParameterSection[];
46+ /** Grey out settings the method ignores */
47+ method?: MethodDefinition;
48+ issues?: ValidationIssue[];
49+ /** Largest stable FD step, when known, for the CFL slider */
50+ fdDtMax?: number;
51+ /** Sections open initially; default all */
52+ open?: ParameterSection[];
53+}
54+
55+/** Controls that edit the spec. Every control maps to one field of the `params` struct. */
56+export function ParameterPanel(props: ParameterPanelProps) {
57+ const { spec, onChange, sections = ALL_SECTIONS, method, issues = [], fdDtMax, open } = props;
58+ const set = (patch: Partial<ProblemSpec>) => onChange({ ...spec, ...patch });
59+ const issue = (path: string) => issues.find((i) => i.path === path || i.path.startsWith(path + "."));
60+ const invalid = (path: string) => issue(path)?.severity === "error";
61+ const show = (s: ParameterSection) => sections.includes(s);
62+ const isOpen = (s: ParameterSection) => (open ? open.includes(s) : true);
63+ const e = spec.excitation;
64+
65+ const materialPresetId = MATERIAL_PRESETS.find(
66+ (p) => p.material.name === spec.material.name && p.material.E === spec.material.E,
67+ )?.id;
68+ const platePresetId = PLATE_PRESETS.find(
69+ (p) => p.plate.Lx === spec.plate.Lx && p.plate.Ly === spec.plate.Ly && p.plate.h === spec.plate.h,
70+ )?.id;
71+
72+ return (
73+ <div className="pl-params">
74+ {show("plate") && (
75+ <Group title="Plate" issues={issues} prefix="plate" open={isOpen("plate")}>
76+ <div className="pl-field pl-field-wide">
77+ <label>Stock</label>
78+ <select
79+ className="pl-select"
80+ value={platePresetId ?? ""}
81+ onChange={(ev) => {
82+ const p = PLATE_PRESETS.find((x) => x.id === ev.target.value);
83+ if (p) set({ plate: { ...p.plate } });
84+ }}
85+ >
86+ <option value="">custom</option>
87+ {PLATE_PRESETS.map((p) => (
88+ <option key={p.id} value={p.id}>
89+ {p.name}
90+ </option>
91+ ))}
92+ </select>
93+ </div>
94+ {platePresetId && <div className="pl-small pl-muted">{PLATE_PRESETS.find((p) => p.id === platePresetId)?.note}</div>}
95+ <NumberField label="Length Lx" value={spec.plate.Lx} scale={1000} unit="mm" invalid={invalid("plate.Lx")} onChange={(v) => set({ plate: { ...spec.plate, Lx: v } })} />
96+ <NumberField label="Width Ly" value={spec.plate.Ly} scale={1000} unit="mm" invalid={invalid("plate.Ly")} onChange={(v) => set({ plate: { ...spec.plate, Ly: v } })} />
97+ <NumberField label="Thickness h" value={spec.plate.h} scale={1000} unit="mm" step={0.1} invalid={invalid("plate.h")} onChange={(v) => set({ plate: { ...spec.plate, h: v } })} />
98+ </Group>
99+ )}
100+ {show("material") && (
101+ <Group title="Material" issues={issues} prefix="material" open={isOpen("material")}>
102+ <div className="pl-field pl-field-wide">
103+ <label>Preset</label>
104+ <select
105+ className="pl-select"
106+ value={materialPresetId ?? ""}
107+ onChange={(ev) => {
108+ const p = MATERIAL_PRESETS.find((x) => x.id === ev.target.value);
109+ if (p) set({ material: { ...p.material }, damping: { ...p.damping } });
110+ }}
111+ >
112+ <option value="">custom</option>
113+ {MATERIAL_PRESETS.map((p) => (
114+ <option key={p.id} value={p.id}>
115+ {p.material.name}
116+ </option>
117+ ))}
118+ </select>
119+ </div>
120+ {materialPresetId && (
121+ <div className="pl-small pl-muted">{MATERIAL_PRESETS.find((p) => p.id === materialPresetId)?.source} Handbook values, not a certificate.</div>
122+ )}
123+ <NumberField label="Young's modulus E" value={spec.material.E} scale={1e-9} unit="GPa" invalid={invalid("material.E")} onChange={(v) => set({ material: { ...spec.material, E: v, name: customName(spec.material.name) } })} />
124+ <NumberField label="Poisson's ratio ν" value={spec.material.nu} step={0.01} invalid={invalid("material.nu")} onChange={(v) => set({ material: { ...spec.material, nu: v, name: customName(spec.material.name) } })} />
125+ <NumberField label="Density ρ" value={spec.material.rho} unit="kg/m³" invalid={invalid("material.rho")} onChange={(v) => set({ material: { ...spec.material, rho: v, name: customName(spec.material.name) } })} />
126+ </Group>
127+ )}
128+ {show("edges") && (
129+ <Group title="Edges" issues={issues} prefix="bc" open={isOpen("edges")}>
130+ <div className="pl-field pl-field-wide">
131+ <label>Condition</label>
132+ <select className="pl-select" value={spec.bc} onChange={(ev) => set({ bc: ev.target.value as BoundaryCondition })}>
133+ <option value="simply-supported">simply supported (w = 0, no moment)</option>
134+ <option value="clamped">clamped (w = 0, no slope)</option>
135+ </select>
136+ </div>
137+ {method && !method.supports.bc.includes(spec.bc) && (
138+ <div className="pl-issue">{method.name} has no {spec.bc} solution; only the finite-difference method can run this.</div>
139+ )}
140+ <div className="pl-small pl-muted">Both are idealizations: a real clamp has finite stiffness and a real support some rotational restraint.</div>
141+ </Group>
142+ )}
143+ {show("damping") && (
144+ <Group title="Damping" issues={issues} prefix="damping" open={isOpen("damping")}>
145+ <NumberField label="σ₀ (all frequencies)" value={spec.damping.sigma0} unit="1/s" step={0.1} invalid={invalid("damping")} onChange={(v) => set({ damping: { ...spec.damping, sigma0: v } })} />
146+ <NumberField label="σ₁ (rises with frequency)" value={spec.damping.sigma1} unit="m²/s" step={0.001} invalid={invalid("damping")} onChange={(v) => set({ damping: { ...spec.damping, sigma1: v } })} />
147+ <div className="pl-small pl-muted">A mode with Laplacian eigenvalue λ decays at rate σ₀ + σ₁λ. Illustrative values; real losses depend on the mounting.</div>
148+ </Group>
149+ )}
150+ {show("excitation") && (
151+ <Group title="Excitation" issues={issues} prefix="excitation" open={isOpen("excitation")}>
152+ <div className="pl-field pl-field-wide">
153+ <label>Type</label>
154+ <select
155+ className="pl-select"
156+ value={e.type}
157+ onChange={(ev) => {
158+ const type = ev.target.value as Excitation["type"];
159+ const next: Excitation =
160+ type === "impulse"
161+ ? { type, x: e.x, y: e.y, force: e.force, duration: 1e-3, radius: e.radius }
162+ : { type, x: e.x, y: e.y, force: Math.min(e.force, 5), frequency: 500, radius: e.radius };
163+ set({ excitation: next });
164+ }}
165+ >
166+ <option value="impulse">hammer strike (half-sine pulse)</option>
167+ <option value="harmonic">harmonic point force</option>
168+ </select>
169+ </div>
170+ <NumberField label="Strike x" value={e.x} scale={1000} unit="mm" invalid={invalid("excitation")} onChange={(v) => set({ excitation: { ...e, x: v } })} />
171+ <NumberField label="Strike y" value={e.y} scale={1000} unit="mm" invalid={invalid("excitation")} onChange={(v) => set({ excitation: { ...e, y: v } })} />
172+ <NumberField label={e.type === "impulse" ? "Peak force" : "Force amplitude"} value={e.force} unit="N" invalid={invalid("excitation.force")} onChange={(v) => set({ excitation: { ...e, force: v } })} />
173+ {e.type === "impulse" ? (
174+ <NumberField label="Contact time" value={e.duration} scale={1000} unit="ms" step={0.1} invalid={invalid("excitation.duration")} onChange={(v) => set({ excitation: { ...e, duration: v } })} />
175+ ) : (
176+ <NumberField label="Frequency" value={e.frequency} unit="Hz" invalid={invalid("excitation.frequency")} onChange={(v) => set({ excitation: { ...e, frequency: v } })} />
177+ )}
178+ <NumberField label="Footprint radius" value={e.radius} scale={1000} unit="mm" step={1} invalid={invalid("excitation.radius")} onChange={(v) => set({ excitation: { ...e, radius: v } })} />
179+ <div className="pl-small pl-muted">A finite footprint stands in for the contact patch; a point load would give infinite stress under the hammer.</div>
180+ </Group>
181+ )}
182+ {show("probe") && (
183+ <Group title="Probe" issues={issues} prefix="probe" open={isOpen("probe")}>
184+ <NumberField label="x" value={spec.probe.x} scale={1000} unit="mm" invalid={invalid("probe")} onChange={(v) => set({ probe: { ...spec.probe, x: v } })} />
185+ <NumberField label="y" value={spec.probe.y} scale={1000} unit="mm" invalid={invalid("probe")} onChange={(v) => set({ probe: { ...spec.probe, y: v } })} />
186+ <div className="pl-small pl-muted">Time series and spectra are recorded here. Finite differences snap it to the nearest grid node.</div>
187+ </Group>
188+ )}
189+ {show("listener") && (
190+ <Group title="Listener (sound)" issues={issues} prefix="listener" open={isOpen("listener")}>
191+ <NumberField label="x" value={spec.listener.x} scale={1000} unit="mm" onChange={(v) => set({ listener: { ...spec.listener, x: v } })} />
192+ <NumberField label="y" value={spec.listener.y} scale={1000} unit="mm" onChange={(v) => set({ listener: { ...spec.listener, y: v } })} />
193+ <NumberField label="Height z" value={spec.listener.z} unit="m" step={0.1} onChange={(v) => set({ listener: { ...spec.listener, z: v } })} />
194+ <div className="pl-small pl-muted">Rayleigh integral in an infinite baffle: an estimate of the waveform, not a calibrated level.</div>
195+ </Group>
196+ )}
197+ {show("output") && (
198+ <Group title="Output" issues={issues} prefix="output" open={isOpen("output")}>
199+ <NumberField label="Sample rate" value={spec.output.fs} unit="Hz" integer invalid={invalid("output.fs")} onChange={(v) => set({ output: { ...spec.output, fs: v } })} />
200+ <NumberField label="Duration (modal, audio)" value={spec.output.duration} unit="s" step={0.1} invalid={invalid("output.duration")} onChange={(v) => set({ output: { ...spec.output, duration: v } })} />
201+ <NumberField label="Animation frames" value={spec.output.nframes} integer invalid={invalid("output.nframes")} onChange={(v) => set({ output: { ...spec.output, nframes: v } })} />
202+ <NumberField label="Display grid nx" value={spec.output.grid[0]} integer onChange={(v) => set({ output: { ...spec.output, grid: [v, spec.output.grid[1]] } })} />
203+ <NumberField label="Display grid ny" value={spec.output.grid[1]} integer onChange={(v) => set({ output: { ...spec.output, grid: [spec.output.grid[0], v] } })} />
204+ </Group>
205+ )}
206+ {show("modal") && (
207+ <Group title="Modal method" issues={issues} prefix="modal" open={isOpen("modal")} disabled={method ? method.settingsKey !== "modal" : false}>
208+ <NumberField label="Max frequency" value={spec.modal.fmax} unit="Hz" invalid={invalid("modal.fmax")} onChange={(v) => set({ modal: { ...spec.modal, fmax: v } })} />
209+ <NumberField label="Max modes" value={spec.modal.max_modes} integer onChange={(v) => set({ modal: { ...spec.modal, max_modes: v } })} />
210+ <div className="pl-small pl-muted">Truncation of the modal sum: displacement converges fast, stress near the strike slowly.</div>
211+ </Group>
212+ )}
213+ {show("fd") && (
214+ <Group title="Explicit finite differences" issues={issues} prefix="fd" open={isOpen("fd")} disabled={method ? method.settingsKey !== "fd" : false}>
215+ <NumberField label="Grid nx" value={spec.fd.nx} integer min={5} invalid={invalid("fd")} onChange={(v) => set({ fd: { ...spec.fd, nx: v } })} />
216+ <NumberField label="Grid ny" value={spec.fd.ny} integer min={5} invalid={invalid("fd")} onChange={(v) => set({ fd: { ...spec.fd, ny: v } })} />
217+ <NumberField label="Duration" value={spec.fd.duration} scale={1000} unit="ms" step={0.5} onChange={(v) => set({ fd: { ...spec.fd, duration: v } })} />
218+ <div className="pl-small" style={{ marginTop: 6 }}>Time step</div>
219+ <CflSlider value={spec.fd.cfl} dtMax={fdDtMax} onChange={(v) => set({ fd: { ...spec.fd, cfl: v } })} />
220+ </Group>
221+ )}
222+ {show("implicit") && (
223+ <Group title="Implicit finite differences" issues={issues} prefix="implicit" open={isOpen("implicit")} disabled={method ? method.settingsKey !== "implicit" : false}>
224+ <NumberField label="Grid nx" value={spec.implicit.nx} integer min={5} invalid={invalid("implicit")} onChange={(v) => set({ implicit: { ...spec.implicit, nx: v } })} />
225+ <NumberField label="Grid ny" value={spec.implicit.ny} integer min={5} invalid={invalid("implicit")} onChange={(v) => set({ implicit: { ...spec.implicit, ny: v } })} />
226+ <NumberField label="Duration" value={spec.implicit.duration} scale={1000} unit="ms" step={5} onChange={(v) => set({ implicit: { ...spec.implicit, duration: v } })} />
227+ <NumberField label="Steps per sample" value={spec.implicit.steps_per_sample} step={0.5} min={0.25} invalid={invalid("implicit.steps_per_sample")} onChange={(v) => set({ implicit: { ...spec.implicit, steps_per_sample: v } })} />
228+ <NumberField label="θ (time averaging)" value={spec.implicit.theta} step={0.05} min={0} max={1} invalid={invalid("implicit.theta")} onChange={(v) => set({ implicit: { ...spec.implicit, theta: v } })} />
229+ <div className="pl-small pl-muted">No stability limit for θ ≥ 1/4; the step sets accuracy instead. More steps per sample tune the high modes better and cost proportionally.</div>
230+ </Group>
231+ )}
232+ </div>
233+ );
234+}
235+
236+function customName(name: string): string {
237+ return name.endsWith(" (edited)") ? name : `${name} (edited)`;
238+}
239+
240+function Group(props: { title: string; prefix: string; issues: ValidationIssue[]; open: boolean; disabled?: boolean; children: ReactNode }) {
241+ const related = props.issues.filter((i) => i.path === props.prefix || i.path.startsWith(props.prefix + "."));
242+ return (
243+ <details className={`pl-group${props.disabled ? " pl-disabled" : ""}`} open={props.open}>
244+ <summary>
245+ {props.title}
246+ {related.some((i) => i.severity === "error") && <span className="pl-badge pl-badge-error" style={{ marginLeft: 6 }}>check</span>}
247+ {props.disabled && <span className="pl-badge pl-badge-info" style={{ marginLeft: 6 }}>not used by this method</span>}
248+ </summary>
249+ {props.children}
250+ {related.map((i) => (
251+ <div key={i.path + i.message} className={`pl-issue${i.severity === "warning" ? " pl-issue-warning" : ""}`}>
252+ {i.path}: {i.message}
253+ </div>
254+ ))}
255+ </details>
256+ );
257+}
packages/components/src/plots/LinePlot.tsxadded+414−0View file
@@ -0,0 +1,414 @@
1+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
2+
3+export interface Series {
4+ x: ArrayLike<number>;
5+ y: ArrayLike<number>;
6+ color?: string;
7+ label?: string;
8+ width?: number;
9+ dash?: number[];
10+}
11+
12+export type XRange = [number, number];
13+
14+export interface LinePlotProps {
15+ series: Series[];
16+ title?: string;
17+ xLabel?: string;
18+ yLabel?: string;
19+ /** Display multipliers (e.g. 1000 to plot seconds as milliseconds) */
20+ xScale?: number;
21+ yScale?: number;
22+ logY?: boolean;
23+ /** Visible x window in data units; null or undefined shows `defaultXRange` or everything */
24+ xRange?: XRange | null;
25+ /** Window shown when not zoomed (data units); zooming out past it up to the data extent is allowed */
26+ defaultXRange?: XRange;
27+ /** Enables zooming: drag to select, wheel to zoom about the pointer, double-click to reset. */
28+ onXRangeChange?: (r: XRange | null) => void;
29+ yRange?: [number, number];
30+ /** Vertical marker at this x (data units), e.g. the animation time */
31+ cursorX?: number;
32+ /** Crosshair with value readout at this x (data units), typically shared between plots */
33+ hoverX?: number | null;
34+ onHover?: (x: number | null) => void;
35+ onClick?: (x: number) => void;
36+ height?: number;
37+ /** Unit shown in the readout */
38+ yUnit?: string;
39+}
40+
41+const DEFAULT_COLORS = ["#1f5fa8", "#d1495b", "#2e8b57", "#8a5cbf", "#e08a1e", "#3aa7b8"];
42+const MARGIN = { left: 60, right: 12, top: 8, bottom: 30 };
43+
44+interface Geometry {
45+ width: number;
46+ height: number;
47+ xmin: number;
48+ xmax: number;
49+ ymin: number;
50+ ymax: number;
51+}
52+
53+/**
54+ * Canvas line plot. The data layer redraws only when the data or the window
55+ * changes; the cursor, crosshair and selection live on a separate overlay so
56+ * hovering costs nothing. Draws the solver's numbers; computes nothing about them.
57+ */
58+export function LinePlot(props: LinePlotProps) {
59+ const { series, title, xLabel, yLabel, xScale = 1, yScale = 1, logY, defaultXRange, onXRangeChange, yRange, cursorX, hoverX, onHover, onClick, height = 200, yUnit } = props;
60+ const xRange = props.xRange ?? defaultXRange ?? null;
61+ const zoomed = !!props.xRange;
62+ const baseRef = useRef<HTMLCanvasElement>(null);
63+ const overRef = useRef<HTMLCanvasElement>(null);
64+ const boxRef = useRef<HTMLDivElement>(null);
65+ const geom = useRef<Geometry>({ width: 400, height, xmin: 0, xmax: 1, ymin: 0, ymax: 1 });
66+ const [width, setWidth] = useState(400);
67+ const [drag, setDrag] = useState<{ x0: number; x1: number } | null>(null);
68+ const dragRef = useRef<{ x0: number; x1: number } | null>(null);
69+
70+ // Track the container width
71+ useLayoutEffect(() => {
72+ const box = boxRef.current;
73+ if (!box) return;
74+ const update = () => setWidth(Math.max(120, box.clientWidth));
75+ update();
76+ const ro = new ResizeObserver(update);
77+ ro.observe(box);
78+ return () => ro.disconnect();
79+ }, []);
80+
81+ const pw = width - MARGIN.left - MARGIN.right;
82+ const ph = height - MARGIN.top - MARGIN.bottom;
83+
84+ // Data extents in display units
85+ const fullX = useRef<XRange>([0, 1]);
86+
87+ // ---- Data layer
88+ useEffect(() => {
89+ const canvas = baseRef.current;
90+ if (!canvas) return;
91+ const dpr = Math.min(2, window.devicePixelRatio || 1);
92+ canvas.width = Math.round(width * dpr);
93+ canvas.height = Math.round(height * dpr);
94+ const ctx = canvas.getContext("2d");
95+ if (!ctx) return;
96+ ctx.save();
97+ ctx.scale(dpr, dpr);
98+ ctx.clearRect(0, 0, width, height);
99+
100+ let dxmin = Infinity, dxmax = -Infinity;
101+ for (const s of series) {
102+ for (let i = 0; i < s.x.length; i++) {
103+ const x = s.x[i] * xScale;
104+ if (!Number.isFinite(x)) continue;
105+ if (x < dxmin) dxmin = x;
106+ if (x > dxmax) dxmax = x;
107+ }
108+ }
109+ if (!Number.isFinite(dxmin) || dxmin === dxmax) [dxmin, dxmax] = [0, 1];
110+ fullX.current = [dxmin, dxmax];
111+ let xmin = dxmin, xmax = dxmax;
112+ if (xRange) [xmin, xmax] = [xRange[0] * xScale, xRange[1] * xScale];
113+ if (xmin === xmax) xmax = xmin + 1;
114+
115+ // y extents over the visible window
116+ let ymin = Infinity, ymax = -Infinity;
117+ for (const s of series) {
118+ for (let i = 0; i < s.x.length; i++) {
119+ const x = s.x[i] * xScale;
120+ if (x < xmin || x > xmax) continue;
121+ let y = s.y[i] * yScale;
122+ if (!Number.isFinite(y)) continue;
123+ if (logY) {
124+ if (y <= 0) continue;
125+ y = Math.log10(y);
126+ }
127+ if (y < ymin) ymin = y;
128+ if (y > ymax) ymax = y;
129+ }
130+ }
131+ if (yRange) [ymin, ymax] = logY ? [Math.log10(yRange[0] * yScale), Math.log10(yRange[1] * yScale)] : [yRange[0] * yScale, yRange[1] * yScale];
132+ if (!Number.isFinite(ymin)) [ymin, ymax] = [0, 1];
133+ if (ymin === ymax) [ymin, ymax] = [ymin - 1, ymax + 1];
134+ if (!yRange && !logY) {
135+ const pad = 0.05 * (ymax - ymin);
136+ ymin -= pad;
137+ ymax += pad;
138+ }
139+ geom.current = { width, height, xmin, xmax, ymin, ymax };
140+ const X = (x: number) => MARGIN.left + ((x - xmin) / (xmax - xmin)) * pw;
141+ const Y = (y: number) => MARGIN.top + (1 - (y - ymin) / (ymax - ymin)) * ph;
142+
143+ ctx.font = "10px system-ui, sans-serif";
144+ ctx.fillStyle = "#5b6573";
145+ ctx.strokeStyle = "#e3e6ea";
146+ ctx.lineWidth = 1;
147+ for (const t of ticks(xmin, xmax, Math.max(3, Math.floor(pw / 70)))) {
148+ const px = X(t);
149+ ctx.beginPath(); ctx.moveTo(px, MARGIN.top); ctx.lineTo(px, MARGIN.top + ph); ctx.stroke();
150+ ctx.textAlign = "center";
151+ ctx.fillText(fmtTick(t), px, MARGIN.top + ph + 12);
152+ }
153+ const yt = logY ? logTicks(ymin, ymax) : ticks(ymin, ymax, 5);
154+ for (const t of yt) {
155+ const py = Y(t);
156+ ctx.beginPath(); ctx.moveTo(MARGIN.left, py); ctx.lineTo(MARGIN.left + pw, py); ctx.stroke();
157+ ctx.textAlign = "right";
158+ ctx.fillText(logY ? fmtTick(Math.pow(10, t)) : fmtTick(t), MARGIN.left - 4, py + 3);
159+ }
160+ ctx.strokeStyle = "#8a93a0";
161+ ctx.strokeRect(MARGIN.left + 0.5, MARGIN.top + 0.5, pw - 1, ph - 1);
162+ if (xLabel) { ctx.textAlign = "center"; ctx.fillText(xLabel, MARGIN.left + pw / 2, height - 4); }
163+ if (yLabel) {
164+ ctx.save(); ctx.translate(10, MARGIN.top + ph / 2); ctx.rotate(-Math.PI / 2); ctx.textAlign = "center"; ctx.fillText(yLabel, 0, 0); ctx.restore();
165+ }
166+
167+ ctx.save();
168+ ctx.beginPath(); ctx.rect(MARGIN.left, MARGIN.top, pw, ph); ctx.clip();
169+ series.forEach((s, si) => {
170+ ctx.strokeStyle = s.color ?? DEFAULT_COLORS[si % DEFAULT_COLORS.length];
171+ ctx.lineWidth = s.width ?? 1.2;
172+ ctx.setLineDash(s.dash ?? []);
173+ ctx.beginPath();
174+ let pen = false;
175+ const n = s.x.length;
176+ // Visible index window (x assumed increasing), decimated to about two points per pixel
177+ const i0 = Math.max(0, lowerBound(s.x, xmin / xScale) - 1);
178+ const i1 = Math.min(n - 1, lowerBound(s.x, xmax / xScale) + 1);
179+ const stride = Math.max(1, Math.floor((i1 - i0) / (pw * 2)));
180+ for (let i = i0; i <= i1; i += stride) {
181+ const x = s.x[i] * xScale;
182+ let y = s.y[i] * yScale;
183+ if (logY) y = y > 0 ? Math.log10(y) : NaN;
184+ if (!Number.isFinite(x) || !Number.isFinite(y)) { pen = false; continue; }
185+ const px = X(x), py = Y(y);
186+ if (pen) ctx.lineTo(px, py); else { ctx.moveTo(px, py); pen = true; }
187+ }
188+ ctx.stroke();
189+ });
190+ ctx.setLineDash([]);
191+ ctx.restore();
192+ ctx.restore();
193+ }, [series, xScale, yScale, logY, xRange, yRange, height, width, xLabel, yLabel, pw, ph]);
194+
195+ // ---- Overlay layer: cursor, crosshair with readout, drag selection
196+ useEffect(() => {
197+ const canvas = overRef.current;
198+ if (!canvas) return;
199+ const dpr = Math.min(2, window.devicePixelRatio || 1);
200+ canvas.width = Math.round(width * dpr);
201+ canvas.height = Math.round(height * dpr);
202+ const ctx = canvas.getContext("2d");
203+ if (!ctx) return;
204+ ctx.save();
205+ ctx.scale(dpr, dpr);
206+ ctx.clearRect(0, 0, width, height);
207+ const g = geom.current;
208+ const X = (x: number) => MARGIN.left + ((x - g.xmin) / (g.xmax - g.xmin)) * pw;
209+ const Y = (y: number) => MARGIN.top + (1 - (y - g.ymin) / (g.ymax - g.ymin)) * ph;
210+ ctx.beginPath(); ctx.rect(MARGIN.left, MARGIN.top, pw, ph); ctx.clip();
211+ if (cursorX !== undefined && Number.isFinite(cursorX)) {
212+ const px = X(cursorX * xScale);
213+ ctx.strokeStyle = "#333"; ctx.lineWidth = 1;
214+ ctx.beginPath(); ctx.moveTo(px, MARGIN.top); ctx.lineTo(px, MARGIN.top + ph); ctx.stroke();
215+ }
216+ if (hoverX !== undefined && hoverX !== null && Number.isFinite(hoverX)) {
217+ const hx = hoverX * xScale;
218+ if (hx >= g.xmin && hx <= g.xmax) {
219+ const px = X(hx);
220+ ctx.strokeStyle = "rgba(0,0,0,0.35)"; ctx.lineWidth = 1; ctx.setLineDash([3, 3]);
221+ ctx.beginPath(); ctx.moveTo(px, MARGIN.top); ctx.lineTo(px, MARGIN.top + ph); ctx.stroke();
222+ ctx.setLineDash([]);
223+ ctx.font = "10px system-ui, sans-serif";
224+ const lines: { text: string; color: string; py: number }[] = [];
225+ series.forEach((s, si) => {
226+ const i = nearestIndex(s.x, hoverX);
227+ if (i < 0) return;
228+ const y = s.y[i] * yScale;
229+ if (!Number.isFinite(y)) return;
230+ const color = s.color ?? DEFAULT_COLORS[si % DEFAULT_COLORS.length];
231+ const py = Y(logY ? (y > 0 ? Math.log10(y) : g.ymin) : y);
232+ ctx.fillStyle = color;
233+ ctx.beginPath(); ctx.arc(px, py, 3, 0, 2 * Math.PI); ctx.fill();
234+ lines.push({ text: `${s.label ? s.label + ": " : ""}${fmtTick(y)}${yUnit ? " " + yUnit : ""}`, color, py });
235+ });
236+ const boxW = Math.max(60, ...lines.map((l) => ctx.measureText(l.text).width + 10));
237+ const right = px + 8 + boxW > MARGIN.left + pw;
238+ const bx = right ? px - 8 - boxW : px + 8;
239+ const by = MARGIN.top + 4;
240+ const boxH = 14 * (lines.length + 1) + 4;
241+ ctx.fillStyle = "rgba(255,255,255,0.92)";
242+ ctx.fillRect(bx, by, boxW, boxH);
243+ ctx.strokeStyle = "#c9ced5"; ctx.strokeRect(bx + 0.5, by + 0.5, boxW - 1, boxH - 1);
244+ ctx.textAlign = "left";
245+ ctx.fillStyle = "#5b6573";
246+ ctx.fillText(`${xLabel ? xLabel.replace(/\s*\[.*\]\s*$/, "") + " " : ""}${fmtTick(hx)}${unitOf(xLabel)}`, bx + 5, by + 12);
247+ lines.forEach((l, k) => { ctx.fillStyle = l.color; ctx.fillText(l.text, bx + 5, by + 12 + 14 * (k + 1)); });
248+ }
249+ }
250+ if (drag) {
251+ const a = Math.min(drag.x0, drag.x1), b = Math.max(drag.x0, drag.x1);
252+ ctx.fillStyle = "rgba(31,95,168,0.15)";
253+ ctx.fillRect(a, MARGIN.top, b - a, ph);
254+ }
255+ ctx.restore();
256+ }, [cursorX, hoverX, drag, series, xScale, yScale, logY, width, height, pw, ph, xLabel, yUnit, xRange]);
257+
258+ // ---- Interaction
259+ const toDataX = useCallback(
260+ (clientX: number, el: HTMLElement) => {
261+ const rect = el.getBoundingClientRect();
262+ const px = clientX - rect.left;
263+ const g = geom.current;
264+ const f = (px - MARGIN.left) / pw;
265+ return { px, x: (g.xmin + f * (g.xmax - g.xmin)) / xScale, inside: f >= 0 && f <= 1 };
266+ },
267+ [pw, xScale],
268+ );
269+
270+ const clampRange = useCallback(
271+ (a: number, b: number): XRange | null => {
272+ const [fmin, fmax] = fullX.current.map((v) => v / xScale) as XRange;
273+ let lo = Math.max(fmin, Math.min(a, b));
274+ let hi = Math.min(fmax, Math.max(a, b));
275+ if (hi - lo < (fmax - fmin) * 1e-4) return null;
276+ if (lo <= fmin && hi >= fmax) return null;
277+ return [lo, hi];
278+ },
279+ [xScale],
280+ );
281+
282+ // Wheel zoom needs a non-passive listener to prevent page scroll
283+ useEffect(() => {
284+ const el = overRef.current;
285+ if (!el || !onXRangeChange) return;
286+ const onWheel = (e: WheelEvent) => {
287+ const { x, inside } = toDataX(e.clientX, el);
288+ if (!inside) return;
289+ e.preventDefault();
290+ const g = geom.current;
291+ const factor = Math.exp(e.deltaY * 0.0015);
292+ const lo = x - (x - g.xmin / xScale) * factor;
293+ const hi = x + (g.xmax / xScale - x) * factor;
294+ onXRangeChange(clampRange(lo, hi));
295+ };
296+ el.addEventListener("wheel", onWheel, { passive: false });
297+ return () => el.removeEventListener("wheel", onWheel);
298+ }, [onXRangeChange, toDataX, clampRange, xScale]);
299+
300+ return (
301+ <div className="pl-plot" ref={boxRef}>
302+ {(title || zoomed) && (
303+ <div className="pl-plot-title">
304+ {title}
305+ {zoomed && onXRangeChange && (
306+ <button className="pl-linkbtn" onClick={() => onXRangeChange(null)} title="double-click the plot also resets">
307+ reset zoom
308+ </button>
309+ )}
310+ </div>
311+ )}
312+ <div style={{ position: "relative", height }}>
313+ <canvas ref={baseRef} style={{ position: "absolute", inset: 0, width: "100%", height }} />
314+ <canvas
315+ ref={overRef}
316+ style={{ position: "absolute", inset: 0, width: "100%", height, cursor: onXRangeChange ? "crosshair" : onClick ? "pointer" : "default" }}
317+ onMouseMove={(e) => {
318+ const { px, x, inside } = toDataX(e.clientX, e.currentTarget);
319+ if (dragRef.current) {
320+ dragRef.current = { ...dragRef.current, x1: px };
321+ setDrag(dragRef.current);
322+ }
323+ onHover?.(inside ? x : null);
324+ }}
325+ onMouseLeave={() => onHover?.(null)}
326+ onMouseDown={(e) => {
327+ if (!onXRangeChange || e.button !== 0) return;
328+ const { px, inside } = toDataX(e.clientX, e.currentTarget);
329+ if (!inside) return;
330+ dragRef.current = { x0: px, x1: px };
331+ setDrag(dragRef.current);
332+ }}
333+ onMouseUp={(e) => {
334+ const d = dragRef.current;
335+ dragRef.current = null;
336+ setDrag(null);
337+ const { x } = toDataX(e.clientX, e.currentTarget);
338+ if (d && Math.abs(d.x1 - d.x0) > 4 && onXRangeChange) {
339+ const g = geom.current;
340+ const toX = (px: number) => (g.xmin + ((px - MARGIN.left) / pw) * (g.xmax - g.xmin)) / xScale;
341+ onXRangeChange(clampRange(toX(d.x0), toX(d.x1)));
342+ } else if (onClick) {
343+ onClick(x);
344+ }
345+ }}
346+ onDoubleClick={() => onXRangeChange?.(null)}
347+ />
348+ </div>
349+ {series.some((s) => s.label) && (
350+ <div className="pl-legend">
351+ {series.map((s, i) => (
352+ <span key={i} style={{ ["--c" as string]: s.color ?? DEFAULT_COLORS[i % DEFAULT_COLORS.length] }}>
353+ {s.label}
354+ </span>
355+ ))}
356+ {onXRangeChange && <span className="pl-hint">drag or scroll to zoom, double-click to reset</span>}
357+ </div>
358+ )}
359+ </div>
360+ );
361+}
362+
363+function unitOf(label?: string): string {
364+ const m = label?.match(/\[(.*)\]\s*$/);
365+ return m ? ` ${m[1]}` : "";
366+}
367+
368+/** First index with x[i] >= v (x increasing). */
369+function lowerBound(x: ArrayLike<number>, v: number): number {
370+ let lo = 0, hi = x.length;
371+ while (lo < hi) {
372+ const mid = (lo + hi) >> 1;
373+ if (x[mid] < v) lo = mid + 1; else hi = mid;
374+ }
375+ return lo;
376+}
377+
378+function nearestIndex(x: ArrayLike<number>, v: number): number {
379+ const n = x.length;
380+ if (n === 0) return -1;
381+ const i = lowerBound(x, v);
382+ if (i <= 0) return 0;
383+ if (i >= n) return n - 1;
384+ return Math.abs(x[i] - v) < Math.abs(x[i - 1] - v) ? i : i - 1;
385+}
386+
387+export function ticks(min: number, max: number, n: number): number[] {
388+ const span = max - min;
389+ if (!(span > 0)) return [min];
390+ const raw = span / n;
391+ const mag = Math.pow(10, Math.floor(Math.log10(raw)));
392+ const norm = raw / mag;
393+ const step = (norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10) * mag;
394+ const out: number[] = [];
395+ for (let t = Math.ceil(min / step) * step; t <= max + 1e-9 * span; t += step) out.push(Number(t.toPrecision(12)));
396+ return out;
397+}
398+
399+function logTicks(lmin: number, lmax: number): number[] {
400+ const lo = Math.ceil(lmin), hi = Math.floor(lmax);
401+ if (hi < lo) return [lmin, lmax];
402+ // at most about six labelled decades, so wide dynamic ranges stay legible
403+ const step = Math.max(1, Math.ceil((hi - lo + 1) / 6));
404+ const out: number[] = [];
405+ for (let e = lo; e <= hi; e += step) out.push(e);
406+ return out;
407+}
408+
409+export function fmtTick(x: number): string {
410+ if (x === 0) return "0";
411+ const a = Math.abs(x);
412+ if (a >= 1e5 || a < 1e-3) return x.toExponential(2).replace("e+", "e");
413+ return String(Number(x.toPrecision(4)));
414+}
packages/components/src/plots/useSharedAxis.tsadded+18−0View file
@@ -0,0 +1,18 @@
1+import { useState } from "react";
2+import type { XRange } from "./LinePlot";
3+
4+/**
5+ * One x window and one hover position shared by several plots, so zooming or
6+ * pointing at one moves them all. Spread the result into each LinePlot.
7+ */
8+export function useSharedAxis() {
9+ const [xRange, setXRange] = useState<XRange | null>(null);
10+ const [hoverX, setHoverX] = useState<number | null>(null);
11+ return {
12+ xRange,
13+ hoverX,
14+ onXRangeChange: setXRange,
15+ onHover: setHoverX,
16+ reset: () => setXRange(null),
17+ };
18+}
packages/components/src/status/EquationsView.tsxadded+22−0View file
@@ -0,0 +1,22 @@
1+import { useMemo } from "react";
2+import katex from "katex";
3+import "katex/dist/katex.min.css";
4+import type { Equation } from "@plate-lab/core";
5+
6+/** Renders a method's equations with their captions. */
7+export function EquationsView({ equations }: { equations: Equation[] }) {
8+ const html = useMemo(
9+ () => equations.map((e) => katex.renderToString(e.tex, { displayMode: true, throwOnError: false })),
10+ [equations],
11+ );
12+ return (
13+ <div className="pl-equations">
14+ {equations.map((e, i) => (
15+ <div className="pl-equation" key={i}>
16+ <div dangerouslySetInnerHTML={{ __html: html[i] }} />
17+ <div className="pl-equation-caption">{e.caption}</div>
18+ </div>
19+ ))}
20+ </div>
21+ );
22+}
packages/components/src/status/RunStatus.tsxadded+122−0View file
@@ -0,0 +1,122 @@
1+import type { BackendInfo, MethodDefinition, PlateResult } from "@plate-lab/core";
2+import type { SolveState } from "../hooks/useSolve";
3+
4+export interface RunStatusProps {
5+ state: SolveState<unknown>;
6+ result?: PlateResult | null;
7+ method?: MethodDefinition;
8+ backend?: BackendInfo | null;
9+ /** Predicted wall time for the next run [ms], shown while idle */
10+ estimateMs?: number;
11+ onCancel?: () => void;
12+}
13+
14+/** Makes the execution visible: what ran, where, how big it was and how long it took. */
15+export function RunStatus({ state, result, method, backend, estimateMs, onCancel }: RunStatusProps) {
16+ const be = state.status === "done" || state.status === "error" ? state.run.backend : backend;
17+ const wallMs = state.status === "done" || state.status === "error" ? state.run.wallMs : null;
18+ return (
19+ <div className="pl-status">
20+ <div className="pl-row">
21+ {state.status === "idle" && <span className="pl-badge pl-badge-info">ready</span>}
22+ {state.status === "running" && (
23+ <>
24+ <span className="pl-badge pl-badge-info">running</span>
25+ {onCancel && (
26+ <button className="pl-btn pl-btn-danger" onClick={onCancel}>
27+ Cancel
28+ </button>
29+ )}
30+ </>
31+ )}
32+ {state.status === "done" && <span className="pl-badge pl-badge-ok">finished</span>}
33+ {state.status === "error" && <span className="pl-badge pl-badge-error">{state.run.cancelled ? "cancelled" : "error"}</span>}
34+ {result && !result.info.stable && <span className="pl-badge pl-badge-error">diverged at step {result.info.diverged_step}</span>}
35+ {method && <span className="pl-muted">{method.name}</span>}
36+ </div>
37+ {state.status === "running" && (
38+ <div className="pl-progress">
39+ <div />
40+ </div>
41+ )}
42+ {state.status === "error" && <div className="pl-error">{state.run.error}</div>}
43+ <table>
44+ <tbody>
45+ <tr>
46+ <td>Backend</td>
47+ <td>
48+ {be ? `${be.engine} ${be.version}, ${be.optimization === "1" ? "JS JIT (opt 1)" : "interpreter (opt 0)"}, ${be.host === "web-worker" ? "Web Worker in this browser" : "Node.js"}` : "numbl (not yet started)"}
49+ </td>
50+ </tr>
51+ <tr>
52+ <td>Wall time</td>
53+ <td>
54+ {wallMs !== null ? (
55+ <>
56+ {fmtMs(wallMs)} in numbl
57+ {result ? ` (${fmtMs(result.info.wall_s * 1000)} inside the solver by tic/toc)` : ""}
58+ </>
59+ ) : state.status === "running" ? (
60+ "…"
61+ ) : estimateMs !== undefined ? (
62+ `about ${fmtMs(estimateMs)} expected`
63+ ) : (
64+ "–"
65+ )}
66+ </td>
67+ </tr>
68+ {result && (
69+ <tr>
70+ <td>Problem size</td>
71+ <td>{problemSize(result)}</td>
72+ </tr>
73+ )}
74+ {result && (
75+ <tr>
76+ <td>Time step</td>
77+ <td>
78+ {fmtTime(result.info.dt)}, {result.info.nsteps.toLocaleString()} steps, {result.t.length.toLocaleString()} samples at the probe
79+ </td>
80+ </tr>
81+ )}
82+ </tbody>
83+ </table>
84+ {result && result.info.notes.length > 0 && (
85+ <div>
86+ {result.info.notes.map((n, i) => (
87+ <div className="pl-note" key={i}>
88+ {n}
89+ </div>
90+ ))}
91+ </div>
92+ )}
93+ {(state.status === "running" || state.status === "done" || state.status === "error") && state.output.length > 0 && (
94+ <details>
95+ <summary className="pl-small pl-muted">Script output ({state.output.length} lines)</summary>
96+ <div className="pl-log">{state.output.join("")}</div>
97+ </details>
98+ )}
99+ </div>
100+ );
101+}
102+
103+function problemSize(r: PlateResult): string {
104+ const x = r.info.extra;
105+ if (r.method === "modal") return `${x.nmodes} modes, ${r.info.ncells.toLocaleString()} display points, ${r.frames.t.length} frames`;
106+ if (r.method === "fd")
107+ return `${x.nx} × ${x.ny} = ${r.info.ncells.toLocaleString()} nodes, ${Number(x.cell_steps).toLocaleString()} cell-steps, ${r.frames.t.length} frames`;
108+ if (r.method === "implicit")
109+ return `${x.nx} × ${x.ny} grid, ${Number(x.nunknowns).toLocaleString()} unknowns, Cholesky factor with ${Number(x.nnz_factor).toLocaleString()} nonzeros in ${(Number(x.factor_s) * 1000).toFixed(0)} ms, ${r.frames.t.length} frames`;
110+ return `${r.info.ncells.toLocaleString()} cells`;
111+}
112+
113+export function fmtMs(ms: number): string {
114+ if (ms < 1000) return `${ms.toFixed(0)} ms`;
115+ return `${(ms / 1000).toFixed(2)} s`;
116+}
117+
118+export function fmtTime(s: number): string {
119+ if (s >= 1) return `${s.toPrecision(3)} s`;
120+ if (s >= 1e-3) return `${(s * 1e3).toPrecision(3)} ms`;
121+ return `${(s * 1e6).toPrecision(3)} µs`;
122+}
packages/components/src/status/ValidityNotes.tsxadded+27−0View file
@@ -0,0 +1,27 @@
1+import type { MethodDefinition, PlateResult } from "@plate-lab/core";
2+
3+/** What the method idealizes in general, and what this particular run approximated. */
4+export function ValidityNotes({ method, result }: { method: MethodDefinition; result?: PlateResult | null }) {
5+ return (
6+ <div className="pl-small">
7+ <ul style={{ margin: "4px 0", paddingLeft: 18 }}>
8+ {method.validity.map((v, i) => (
9+ <li key={i}>{v}</li>
10+ ))}
11+ </ul>
12+ {result && result.info.notes.length > 0 && (
13+ <>
14+ <div className="pl-muted" style={{ marginTop: 6 }}>
15+ This run:
16+ </div>
17+ <ul style={{ margin: "4px 0", paddingLeft: 18 }}>
18+ {result.info.notes.map((n, i) => (
19+ <li key={i}>{n}</li>
20+ ))}
21+ </ul>
22+ </>
23+ )}
24+ {result?.modes?.truncated && <div className="pl-note">The modal sum was truncated at max_modes; raise it to check convergence of the stress.</div>}
25+ </div>
26+ );
27+}
packages/components/src/styles.cssadded+104−0View file
@@ -0,0 +1,104 @@
1+/* Plate lab components. Prefix: pl- */
2+:root {
3+ --pl-bg: #ffffff;
4+ --pl-panel: #f6f7f9;
5+ --pl-border: #d9dde3;
6+ --pl-text: #1c2430;
7+ --pl-muted: #5b6573;
8+ --pl-accent: #1f5fa8;
9+ --pl-warn: #a35a00;
10+ --pl-warn-bg: #fff4e0;
11+ --pl-error: #a11d2b;
12+ --pl-error-bg: #fde9eb;
13+ --pl-ok: #1f7a3a;
14+ --pl-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
15+ --pl-font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
16+}
17+.pl-panel { background: var(--pl-panel); border: 1px solid var(--pl-border); border-radius: 6px; padding: 10px 12px; }
18+.pl-panel h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--pl-muted); }
19+.pl-muted { color: var(--pl-muted); }
20+.pl-small { font-size: 12px; }
21+.pl-mono { font-family: var(--pl-mono); }
22+.pl-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
23+.pl-badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; }
24+.pl-badge-ok { background: #e3f4e8; color: var(--pl-ok); }
25+.pl-badge-warn { background: var(--pl-warn-bg); color: var(--pl-warn); }
26+.pl-badge-error { background: var(--pl-error-bg); color: var(--pl-error); }
27+.pl-badge-info { background: #e6eef9; color: var(--pl-accent); }
28+.pl-note { padding: 6px 8px; border-left: 3px solid var(--pl-warn); background: var(--pl-warn-bg); font-size: 12px; margin: 4px 0; }
29+.pl-error { padding: 8px 10px; border-left: 3px solid var(--pl-error); background: var(--pl-error-bg); font-family: var(--pl-mono); font-size: 12px; white-space: pre-wrap; }
30+button.pl-btn { font: inherit; padding: 5px 12px; border-radius: 5px; border: 1px solid var(--pl-border); background: #fff; cursor: pointer; color: var(--pl-text); }
31+button.pl-btn:hover { border-color: var(--pl-accent); }
32+button.pl-btn:disabled { opacity: 0.5; cursor: default; }
33+button.pl-btn-primary { background: var(--pl-accent); color: #fff; border-color: var(--pl-accent); }
34+button.pl-btn-danger { background: #fff; color: var(--pl-error); border-color: var(--pl-error); }
35+
36+/* Parameter panel */
37+.pl-params { display: flex; flex-direction: column; gap: 10px; }
38+.pl-group { border: 1px solid var(--pl-border); border-radius: 6px; padding: 8px 10px; background: #fff; }
39+.pl-group > summary, .pl-group-title { font-weight: 600; font-size: 13px; cursor: pointer; margin-bottom: 6px; }
40+.pl-field { display: grid; grid-template-columns: 1fr 100px 46px; gap: 6px; align-items: center; font-size: 12px; margin: 3px 0; }
41+.pl-field label { color: var(--pl-text); }
42+.pl-field input, .pl-field select, .pl-select { font: inherit; font-size: 12px; padding: 3px 5px; border: 1px solid var(--pl-border); border-radius: 4px; width: 100%; box-sizing: border-box; }
43+.pl-field input.pl-invalid { border-color: var(--pl-error); background: var(--pl-error-bg); }
44+.pl-field .pl-unit { color: var(--pl-muted); font-size: 11px; }
45+.pl-field-wide { grid-template-columns: 1fr 2fr; }
46+.pl-slider { display: grid; grid-template-columns: 1fr 70px; gap: 8px; align-items: center; font-size: 12px; }
47+.pl-slider input[type=range] { width: 100%; }
48+.pl-issue { font-size: 11px; color: var(--pl-error); margin: 2px 0 4px; }
49+.pl-issue-warning { color: var(--pl-warn); }
50+.pl-disabled { opacity: 0.55; }
51+
52+/* Code view */
53+.pl-code { display: flex; flex-direction: column; border: 1px solid var(--pl-border); border-radius: 6px; overflow: hidden; background: #fff; min-height: 0; }
54+.pl-code-tabs { display: flex; flex-wrap: wrap; gap: 2px; padding: 4px 6px; background: var(--pl-panel); border-bottom: 1px solid var(--pl-border); }
55+.pl-code-tab { font: inherit; font-size: 12px; font-family: var(--pl-mono); padding: 3px 8px; border: 1px solid transparent; background: transparent; border-radius: 4px; cursor: pointer; color: var(--pl-muted); }
56+.pl-code-tab.pl-active { background: #fff; border-color: var(--pl-border); color: var(--pl-text); }
57+.pl-code-tab.pl-modified::after { content: " *"; color: var(--pl-warn); }
58+.pl-code-toolbar { display: flex; gap: 8px; align-items: center; padding: 4px 8px; font-size: 12px; border-bottom: 1px solid var(--pl-border); color: var(--pl-muted); }
59+.pl-code-editor { flex: 1; min-height: 200px; overflow: auto; }
60+.pl-code-editor .cm-editor { height: 100%; font-size: 12.5px; }
61+.pl-code-editor .cm-scroller { font-family: var(--pl-mono); }
62+
63+/* Field view */
64+.pl-fieldview { display: flex; flex-direction: column; gap: 6px; }
65+.pl-fieldview-head { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; }
66+.pl-fieldview-body { display: grid; grid-template-columns: 1fr 56px; gap: 8px; align-items: start; }
67+.pl-fieldview canvas { width: 100%; height: auto; display: block; image-rendering: auto; border: 1px solid var(--pl-border); background: #fff; }
68+.pl-colorbar { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; font-size: 10px; color: var(--pl-muted); text-align: left; }
69+.pl-colorbar canvas { width: 14px; height: 100%; min-height: 0; border: 1px solid var(--pl-border); }
70+.pl-player { display: grid; grid-template-columns: auto 1fr auto auto; gap: 8px; align-items: center; font-size: 12px; }
71+.pl-player input[type=range] { width: 100%; }
72+
73+/* Plots */
74+.pl-plot { position: relative; }
75+.pl-plot canvas { width: 100%; height: auto; display: block; }
76+.pl-plot-title { font-size: 12px; font-weight: 600; margin-bottom: 2px; }
77+.pl-legend { display: flex; gap: 12px; font-size: 11px; color: var(--pl-muted); flex-wrap: wrap; }
78+.pl-legend span::before { content: ""; display: inline-block; width: 12px; height: 3px; margin-right: 4px; vertical-align: middle; background: var(--c); }
79+
80+/* Equations */
81+.pl-equations { display: flex; flex-direction: column; gap: 10px; }
82+.pl-equation { font-size: 13px; }
83+.pl-equation .katex-display { margin: 4px 0; overflow-x: auto; overflow-y: hidden; }
84+.pl-equation-caption { font-size: 12px; color: var(--pl-muted); }
85+
86+/* Status */
87+.pl-status { font-size: 12px; display: flex; flex-direction: column; gap: 6px; }
88+.pl-status table { border-collapse: collapse; }
89+.pl-status td { padding: 1px 10px 1px 0; vertical-align: top; }
90+.pl-status td:first-child { color: var(--pl-muted); }
91+.pl-log { font-family: var(--pl-mono); font-size: 11px; white-space: pre-wrap; max-height: 120px; overflow: auto; background: #fff; border: 1px solid var(--pl-border); padding: 4px 6px; }
92+.pl-progress { height: 4px; background: var(--pl-border); border-radius: 2px; overflow: hidden; }
93+.pl-progress > div { height: 100%; background: var(--pl-accent); animation: pl-indeterminate 1.2s infinite ease-in-out; width: 30%; }
94+@keyframes pl-indeterminate { 0% { margin-left: -30%; } 100% { margin-left: 100%; } }
95+
96+/* Compare */
97+.pl-metrics { border-collapse: collapse; font-size: 12px; }
98+.pl-metrics th, .pl-metrics td { text-align: right; padding: 2px 10px; border-bottom: 1px solid var(--pl-border); }
99+.pl-metrics th:first-child, .pl-metrics td:first-child { text-align: left; }
100+
101+/* Plot interaction */
102+.pl-linkbtn { font: inherit; font-size: 11px; font-weight: 400; margin-left: 8px; border: 0; background: none; color: var(--pl-accent); cursor: pointer; text-decoration: underline; padding: 0; }
103+.pl-hint { margin-left: auto; font-style: italic; }
104+.pl-legend .pl-hint::before { display: none; }
packages/components/tsconfig.jsonadded+9−0View file
@@ -0,0 +1,9 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ "noEmit": true
5+ },
6+ "include": [
7+ "src"
8+ ]
9+}
packages/core/package.jsonadded+19−0View file
@@ -0,0 +1,19 @@
1+{
2+ "name": "@plate-lab/core",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "description": "Problem specification, .m source generation, numbl runner, and field/plot primitives for the plate mechanics lab. Framework-agnostic.",
7+ "exports": {
8+ ".": "./src/index.ts",
9+ "./worker": "./src/runner/worker.ts",
10+ "./node": "./src/node.ts"
11+ },
12+ "scripts": {
13+ "typecheck": "tsc -p tsconfig.json --noEmit",
14+ "test": "vitest run"
15+ },
16+ "dependencies": {
17+ "numbl": "0.4.21"
18+ }
19+}
packages/core/src/audio/buffer.tsadded+34−0View file
@@ -0,0 +1,34 @@
1+/**
2+ * Turn a pressure signal into something Web Audio can play. Peak normalization
3+ * is a playback convenience (the absolute level of the Rayleigh estimate is not
4+ * calibrated anyway) and is reported so the interface can say so.
5+ */
6+export interface AudioPrep {
7+ samples: Float32Array;
8+ fs: number;
9+ /** Peak of the original signal [Pa] */
10+ peakPa: number;
11+ /** Gain applied so the peak sits at `target` */
12+ gain: number;
13+ durationS: number;
14+}
15+
16+export function prepareAudio(p: Float64Array, fs: number, target = 0.8): AudioPrep {
17+ let peak = 0;
18+ for (let i = 0; i < p.length; i++) {
19+ const a = Math.abs(p[i]);
20+ if (a > peak) peak = a;
21+ }
22+ const gain = peak > 0 ? target / peak : 1;
23+ const samples = new Float32Array(p.length);
24+ for (let i = 0; i < p.length; i++) samples[i] = Number.isFinite(p[i]) ? p[i] * gain : 0;
25+ return { samples, fs, peakPa: peak, gain, durationS: p.length / fs };
26+}
27+
28+export function toAudioBuffer(ctx: BaseAudioContext, prep: AudioPrep): AudioBuffer {
29+ const n = Math.max(1, prep.samples.length);
30+ const buf = ctx.createBuffer(1, n, prep.fs);
31+ const data = (prep.samples.length ? prep.samples : new Float32Array(1)) as Float32Array<ArrayBuffer>;
32+ buf.copyToChannel(data, 0);
33+ return buf;
34+}
packages/core/src/cost.tsadded+28−0View file
@@ -0,0 +1,28 @@
1+import type { MethodDefinition } from "./methods/types";
2+
3+/**
4+ * Wall-time estimate for grid-based methods. This is a calibration, not
5+ * physics: the plan (cell count, steps) comes from the method's plan function
6+ * running in numbl; only the seconds-per-cell-step constant lives here, seeded
7+ * from the method definition and refined by each finished run.
8+ */
9+export class CostModel {
10+ private us = new Map<string, number>();
11+ private readonly overheadMs = 150;
12+
13+ private rate(method: MethodDefinition): number {
14+ return this.us.get(method.id) ?? method.usPerCellStep;
15+ }
16+
17+ estimateMs(method: MethodDefinition, cellSteps: number): number {
18+ return (cellSteps * this.rate(method)) / 1000 + this.overheadMs;
19+ }
20+
21+ /** Blend a measurement in, weighting recent runs more. */
22+ observe(method: MethodDefinition, cellSteps: number, wallMs: number): void {
23+ if (cellSteps <= 0 || wallMs <= 0) return;
24+ const measured = ((wallMs - this.overheadMs) * 1000) / cellSteps;
25+ if (!Number.isFinite(measured) || measured <= 0) return;
26+ this.us.set(method.id, 0.5 * this.rate(method) + 0.5 * measured);
27+ }
28+}
packages/core/src/fields/colormap.tsadded+89−0View file
@@ -0,0 +1,89 @@
1+/**
2+ * Colormaps for field display. Display only: these map numbers the solver
3+ * produced to pixels and never change the numbers.
4+ */
5+export type RGB = [number, number, number];
6+
7+export interface Colormap {
8+ id: string;
9+ name: string;
10+ /** Kind of data it suits */
11+ kind: "sequential" | "diverging";
12+ /** t in [0, 1] -> RGB in [0, 255] */
13+ at(t: number): RGB;
14+}
15+
16+// Polynomial fit of matplotlib's viridis (Matt Zucker, public domain).
17+function viridis(t: number): RGB {
18+ const c0 = [0.2777273272234177, 0.005407344544966578, 0.3340998053353061];
19+ const c1 = [0.1050930431085774, 1.404613529898575, 1.384590162594685];
20+ const c2 = [-0.3308618287255563, 0.214847559468213, 0.09509516302823659];
21+ const c3 = [-4.634230498983486, -5.799100973351585, -19.33244095627987];
22+ const c4 = [6.228269936347081, 14.17993336680509, 56.69055260068105];
23+ const c5 = [4.776384997670288, -13.74514537774601, -65.35303263337234];
24+ const c6 = [-5.435455855934631, 4.645852612178535, 26.3124352495832];
25+ const out: RGB = [0, 0, 0];
26+ for (let i = 0; i < 3; i++) {
27+ const v = c0[i] + t * (c1[i] + t * (c2[i] + t * (c3[i] + t * (c4[i] + t * (c5[i] + t * c6[i])))));
28+ out[i] = Math.round(255 * Math.min(1, Math.max(0, v)));
29+ }
30+ return out;
31+}
32+
33+// Polynomial fit of matplotlib's magma (same source).
34+function magma(t: number): RGB {
35+ const c0 = [-0.002136485053939582, -0.000749655052795221, -0.005386127855323933];
36+ const c1 = [0.2516605407371642, 0.6775232436837668, 2.494026599312351];
37+ const c2 = [8.353717279216625, -3.577719514958484, 0.3144679030132573];
38+ const c3 = [-27.66873308576866, 14.26473078096533, -13.64921318813922];
39+ const c4 = [52.17613981234068, -27.94360607168351, 12.94416944238394];
40+ const c5 = [-50.76852536473588, 29.04658282127291, 4.23415299384598];
41+ const c6 = [18.65570506591883, -11.48977351997711, -5.601961508734096];
42+ const out: RGB = [0, 0, 0];
43+ for (let i = 0; i < 3; i++) {
44+ const v = c0[i] + t * (c1[i] + t * (c2[i] + t * (c3[i] + t * (c4[i] + t * (c5[i] + t * c6[i])))));
45+ out[i] = Math.round(255 * Math.min(1, Math.max(0, v)));
46+ }
47+ return out;
48+}
49+
50+// Blue-white-red diverging map for signed fields.
51+function blueWhiteRed(t: number): RGB {
52+ const stops: RGB[] = [
53+ [5, 48, 97],
54+ [67, 147, 195],
55+ [209, 229, 240],
56+ [247, 247, 247],
57+ [253, 219, 199],
58+ [214, 96, 77],
59+ [103, 0, 31],
60+ ];
61+ const x = Math.min(1, Math.max(0, t)) * (stops.length - 1);
62+ const i = Math.min(stops.length - 2, Math.floor(x));
63+ const f = x - i;
64+ const a = stops[i];
65+ const b = stops[i + 1];
66+ return [Math.round(a[0] + f * (b[0] - a[0])), Math.round(a[1] + f * (b[1] - a[1])), Math.round(a[2] + f * (b[2] - a[2]))];
67+}
68+
69+export const COLORMAPS: Colormap[] = [
70+ { id: "viridis", name: "Viridis", kind: "sequential", at: viridis },
71+ { id: "magma", name: "Magma", kind: "sequential", at: magma },
72+ { id: "bwr", name: "Blue-white-red", kind: "diverging", at: blueWhiteRed },
73+];
74+
75+export function getColormap(id: string): Colormap {
76+ return COLORMAPS.find((c) => c.id === id) ?? COLORMAPS[0];
77+}
78+
79+/** Precompute a 256-entry lookup table for fast pixel fills. */
80+export function colormapTable(cm: Colormap): Uint8ClampedArray {
81+ const table = new Uint8ClampedArray(256 * 3);
82+ for (let k = 0; k < 256; k++) {
83+ const [r, g, b] = cm.at(k / 255);
84+ table[3 * k] = r;
85+ table[3 * k + 1] = g;
86+ table[3 * k + 2] = b;
87+ }
88+ return table;
89+}
packages/core/src/fields/field.tsadded+49−0View file
@@ -0,0 +1,49 @@
1+/**
2+ * Column-major 2-D fields matching numbl's layout: rows index y, columns index x,
3+ * element (i, j) at data[i + j*ny].
4+ */
5+export interface Field2D {
6+ nx: number;
7+ ny: number;
8+ data: Float64Array;
9+}
10+
11+/** A stack of frames: element (i, j, k) at data[i + j*ny + k*ny*nx]. */
12+export interface FieldFrames {
13+ nx: number;
14+ ny: number;
15+ nf: number;
16+ data: Float64Array;
17+}
18+
19+export function frameOf(frames: FieldFrames, k: number): Field2D {
20+ const n = frames.nx * frames.ny;
21+ return { nx: frames.nx, ny: frames.ny, data: frames.data.subarray(k * n, (k + 1) * n) };
22+}
23+
24+export function fieldAt(f: Field2D, i: number, j: number): number {
25+ return f.data[i + j * f.ny];
26+}
27+
28+export interface Range {
29+ min: number;
30+ max: number;
31+}
32+
33+export function rangeOf(data: ArrayLike<number>): Range {
34+ let min = Infinity;
35+ let max = -Infinity;
36+ for (let k = 0; k < data.length; k++) {
37+ const v = data[k];
38+ if (v < min) min = v;
39+ if (v > max) max = v;
40+ }
41+ if (!Number.isFinite(min)) return { min: 0, max: 0 };
42+ return { min, max };
43+}
44+
45+/** Symmetric range about zero, for signed fields drawn with a diverging colormap. */
46+export function symmetricRange(r: Range): Range {
47+ const a = Math.max(Math.abs(r.min), Math.abs(r.max));
48+ return { min: -a, max: a };
49+}
packages/core/src/fields/raster.tsadded+37−0View file
@@ -0,0 +1,37 @@
1+import type { Field2D, Range } from "./field";
2+import { colormapTable, type Colormap } from "./colormap";
3+
4+/**
5+ * Paint a field into RGBA pixels with y increasing upward (row 0 of the image is
6+ * the top of the plate, y = Ly). Non-finite values are drawn transparent.
7+ */
8+export function rasterizeField(
9+ field: Field2D,
10+ range: Range,
11+ cm: Colormap,
12+ out?: Uint8ClampedArray,
13+): Uint8ClampedArray {
14+ const { nx, ny, data } = field;
15+ const table = colormapTable(cm);
16+ const rgba = out && out.length === nx * ny * 4 ? out : new Uint8ClampedArray(nx * ny * 4);
17+ const span = range.max - range.min || 1;
18+ for (let i = 0; i < ny; i++) {
19+ const row = ny - 1 - i;
20+ for (let j = 0; j < nx; j++) {
21+ const v = data[i + j * ny];
22+ const px = (row * nx + j) * 4;
23+ if (!Number.isFinite(v)) {
24+ rgba[px + 3] = 0;
25+ continue;
26+ }
27+ let t = (v - range.min) / span;
28+ t = t < 0 ? 0 : t > 1 ? 1 : t;
29+ const k = Math.round(t * 255) * 3;
30+ rgba[px] = table[k];
31+ rgba[px + 1] = table[k + 1];
32+ rgba[px + 2] = table[k + 2];
33+ rgba[px + 3] = 255;
34+ }
35+ }
36+ return rgba;
37+}
packages/core/src/generate/bundle.tsadded+72−0View file
@@ -0,0 +1,72 @@
1+import type { ProblemSpec } from "../spec/types";
2+import type { MethodDefinition } from "../methods/types";
3+import { generateParamsBlock } from "./params";
4+import { LIBRARY_FILES, type SourceFile } from "./library";
5+
6+/** A complete, self-contained set of `.m` files plus the one to run. */
7+export interface SourceBundle {
8+ files: SourceFile[];
9+ /** File executed as the script */
10+ entry: string;
11+ /** Workspace variable read back after the run */
12+ resultVariable: string;
13+}
14+
15+export const MAIN_FILE = "main.m";
16+
17+/** The script a method runs: the params block followed by the solver call. */
18+export function generateMain(spec: ProblemSpec, method: MethodDefinition): string {
19+ return [
20+ generateParamsBlock(spec),
21+ "",
22+ `% Solve. ${method.solverFunction} and the functions it calls are the library files in the other tabs.`,
23+ `result = ${method.solverFunction}(params);`,
24+ "",
25+ ].join("\n");
26+}
27+
28+/** Bundle for running one method on a spec. */
29+export function buildRunBundle(spec: ProblemSpec, method: MethodDefinition): SourceBundle {
30+ return {
31+ files: [{ name: MAIN_FILE, source: generateMain(spec, method) }, ...LIBRARY_FILES],
32+ entry: MAIN_FILE,
33+ resultVariable: "result",
34+ };
35+}
36+
37+/** Bundle that evaluates a method's cost plan (currently only the FD method has one). */
38+export function buildPlanBundle(spec: ProblemSpec, method: MethodDefinition): SourceBundle | null {
39+ if (!method.planFunction) return null;
40+ const main = [generateParamsBlock(spec), "", `plan = ${method.planFunction}(params);`, ""].join("\n");
41+ return {
42+ files: [{ name: "plan.m", source: main }, ...LIBRARY_FILES],
43+ entry: "plan.m",
44+ resultVariable: "plan",
45+ };
46+}
47+
48+/** Bundle comparing two results, which the runner passes in as workspace variables A and B. */
49+export function buildCompareBundle(): SourceBundle {
50+ const main = [
51+ "% A and B are two result structs for the same problem (A: modal, B: finite differences).",
52+ "cmp = plate_compare(A, B);",
53+ "",
54+ ].join("\n");
55+ return {
56+ files: [{ name: "compare.m", source: main }, ...LIBRARY_FILES],
57+ entry: "compare.m",
58+ resultVariable: "cmp",
59+ };
60+}
61+
62+/**
63+ * Replace files in a bundle with edited versions, keeping everything else. Used
64+ * when the user edits source in the code view: the run then uses exactly the
65+ * text on screen.
66+ */
67+export function withEdits(bundle: SourceBundle, edits: Record<string, string>): SourceBundle {
68+ return {
69+ ...bundle,
70+ files: bundle.files.map((f) => (edits[f.name] !== undefined ? { ...f, source: edits[f.name] } : f)),
71+ };
72+}
packages/core/src/generate/format.tsadded+37−0View file
@@ -0,0 +1,37 @@
1+/** Format a number as a MATLAB literal that round-trips exactly and reads well. */
2+export function formatNumber(x: number): string {
3+ if (!Number.isFinite(x)) return x > 0 ? "Inf" : x < 0 ? "-Inf" : "NaN";
4+ if (x === 0) return "0";
5+ const ax = Math.abs(x);
6+ if (Number.isInteger(x) && ax < 1e6) return String(x);
7+ if (ax >= 1e5 || ax < 1e-4) {
8+ // shortest exponential form that round-trips, written like 200e9 rather than 2e+11
9+ for (let p = 0; p <= 16; p++) {
10+ const s = x.toExponential(p);
11+ if (Number(s) === x) return tidyExponential(s);
12+ }
13+ return tidyExponential(x.toExponential());
14+ }
15+ return String(x);
16+}
17+
18+function tidyExponential(s: string): string {
19+ const [mant, expo] = s.split("e");
20+ const e = Number(expo);
21+ // Prefer engineering-style exponents (multiples of 3) when the mantissa stays short
22+ const shift = ((e % 3) + 3) % 3;
23+ if (shift !== 0) {
24+ const mNum = Number(mant) * Math.pow(10, shift);
25+ const mStr = String(Number(mNum.toPrecision(15)));
26+ if (mStr.length <= 8) return `${mStr}e${e - shift}`;
27+ }
28+ return `${mant}e${e}`;
29+}
30+
31+export function formatString(s: string): string {
32+ return `'${s.replace(/'/g, "''")}'`;
33+}
34+
35+export function formatVector(v: readonly number[]): string {
36+ return `[${v.map(formatNumber).join(" ")}]`;
37+}
packages/core/src/generate/library.tsadded+56−0View file
@@ -0,0 +1,56 @@
1+import plate_modal from "../m/plate_modal.m?raw";
2+import plate_fd from "../m/plate_fd.m?raw";
3+import plate_fd_plan from "../m/plate_fd_plan.m?raw";
4+import plate_setup from "../m/plate_setup.m?raw";
5+import plate_force_time from "../m/plate_force_time.m?raw";
6+import plate_force_footprint from "../m/plate_force_footprint.m?raw";
7+import plate_stress from "../m/plate_stress.m?raw";
8+import plate_sdof from "../m/plate_sdof.m?raw";
9+import plate_listener from "../m/plate_listener.m?raw";
10+import plate_spectrum from "../m/plate_spectrum.m?raw";
11+import plate_pad from "../m/plate_pad.m?raw";
12+import plate_laplacian from "../m/plate_laplacian.m?raw";
13+import plate_curvature from "../m/plate_curvature.m?raw";
14+import plate_compare from "../m/plate_compare.m?raw";
15+import plate_implicit from "../m/plate_implicit.m?raw";
16+import plate_implicit_plan from "../m/plate_implicit_plan.m?raw";
17+import plate_matrices from "../m/plate_matrices.m?raw";
18+import plate_result_schema from "../m/plate_result_schema.m?raw";
19+
20+export interface SourceFile {
21+ name: string;
22+ source: string;
23+}
24+
25+export interface LibraryFile extends SourceFile {
26+ /** One line for the code view's file list */
27+ summary: string;
28+}
29+
30+/** Every `.m` file the solvers can call. The runner ships all of them with each run. */
31+export const LIBRARY_FILES: LibraryFile[] = [
32+ { name: "plate_modal.m", source: plate_modal, summary: "Modal superposition on a simply supported rectangle" },
33+ { name: "plate_fd.m", source: plate_fd, summary: "Explicit finite-difference time stepping" },
34+ { name: "plate_fd_plan.m", source: plate_fd_plan, summary: "Grid, stable time step and cost of the FD scheme" },
35+ { name: "plate_implicit.m", source: plate_implicit, summary: "Implicit theta-scheme: sparse Cholesky once, two triangular solves per step" },
36+ { name: "plate_implicit_plan.m", source: plate_implicit_plan, summary: "Grid, accuracy-chosen time step and size of the implicit scheme" },
37+ { name: "plate_matrices.m", source: plate_matrices, summary: "Sparse Laplacian and biharmonic matrices on the interior nodes" },
38+ { name: "plate_setup.m", source: plate_setup, summary: "Bending stiffness and other derived constants" },
39+ { name: "plate_force_time.m", source: plate_force_time, summary: "Force history F(t): hammer pulse or harmonic drive" },
40+ { name: "plate_force_footprint.m", source: plate_force_footprint, summary: "Raised-cosine spatial footprint of the load" },
41+ { name: "plate_stress.m", source: plate_stress, summary: "Surface stresses, von Mises and principal stress from curvatures" },
42+ { name: "plate_sdof.m", source: plate_sdof, summary: "Exact step response of one damped oscillator" },
43+ { name: "plate_listener.m", source: plate_listener, summary: "Rayleigh-integral weights and delays to the listener" },
44+ { name: "plate_spectrum.m", source: plate_spectrum, summary: "Windowed amplitude spectrum" },
45+ { name: "plate_pad.m", source: plate_pad, summary: "Ghost layer implementing the edge conditions" },
46+ { name: "plate_laplacian.m", source: plate_laplacian, summary: "Five-point Laplacian" },
47+ { name: "plate_curvature.m", source: plate_curvature, summary: "Second derivatives by central differences" },
48+ { name: "plate_compare.m", source: plate_compare, summary: "Error norms between two solutions" },
49+ { name: "plate_result_schema.m", source: plate_result_schema, summary: "Documentation of the result struct" },
50+];
51+
52+export function libraryFile(name: string): LibraryFile {
53+ const f = LIBRARY_FILES.find((x) => x.name === name);
54+ if (!f) throw new Error(`unknown library file ${name}`);
55+ return f;
56+}
packages/core/src/generate/params.tsadded+31−0View file
@@ -0,0 +1,31 @@
1+import type { ProblemSpec } from "../spec/types";
2+import { formatNumber as n, formatString as s, formatVector as vec } from "./format";
3+
4+/**
5+ * Render the specification as the MATLAB `params` struct. The controls edit the
6+ * spec; this block is what the solver actually reads, so the two cannot drift.
7+ */
8+export function generateParamsBlock(spec: ProblemSpec): string {
9+ const e = spec.excitation;
10+ const excitation =
11+ e.type === "impulse"
12+ ? `struct('type', 'impulse', 'x', ${n(e.x)}, 'y', ${n(e.y)}, 'force', ${n(e.force)}, 'duration', ${n(e.duration)}, 'radius', ${n(e.radius)}); % half-sine hammer pulse: strike point [m], peak force [N], contact time [s], footprint radius [m]`
13+ : `struct('type', 'harmonic', 'x', ${n(e.x)}, 'y', ${n(e.y)}, 'force', ${n(e.force)}, 'frequency', ${n(e.frequency)}, 'radius', ${n(e.radius)}); % sinusoidal drive: point [m], amplitude [N], frequency [Hz], footprint radius [m]`;
14+ const lines = [
15+ "% Problem specification, generated from the controls. SI units: m, s, N, Pa, kg/m^3.",
16+ "% Edit anything here or in the library files; the controls regenerate only this block.",
17+ "params = struct();",
18+ `params.plate = struct('Lx', ${n(spec.plate.Lx)}, 'Ly', ${n(spec.plate.Ly)}, 'h', ${n(spec.plate.h)}); % side lengths and thickness [m]`,
19+ `params.material = struct('name', ${s(spec.material.name)}, 'E', ${n(spec.material.E)}, 'nu', ${n(spec.material.nu)}, 'rho', ${n(spec.material.rho)}); % Young's modulus [Pa], Poisson's ratio, density [kg/m^3]`,
20+ `params.bc = ${s(spec.bc)}; % 'simply-supported' or 'clamped' (clamped: finite differences only)`,
21+ `params.damping = struct('sigma0', ${n(spec.damping.sigma0)}, 'sigma1', ${n(spec.damping.sigma1)}); % loss rates: frequency-independent [1/s], frequency-dependent [m^2/s]`,
22+ `params.excitation = ${excitation}`,
23+ `params.probe = struct('x', ${n(spec.probe.x)}, 'y', ${n(spec.probe.y)}); % where the time series are recorded [m]`,
24+ `params.listener = struct('x', ${n(spec.listener.x)}, 'y', ${n(spec.listener.y)}, 'z', ${n(spec.listener.z)}, 'c', ${n(spec.listener.c)}, 'rho_air', ${n(spec.listener.rho_air)}, 'patches', ${vec(spec.listener.patches)}); % microphone position [m], sound speed [m/s], air density [kg/m^3], Rayleigh patch grid`,
25+ `params.output = struct('fs', ${n(spec.output.fs)}, 'duration', ${n(spec.output.duration)}, 'nframes', ${n(spec.output.nframes)}, 'grid', ${vec(spec.output.grid)}); % sample rate [Hz], simulated time [s], animation frames, display grid [nx ny]`,
26+ `params.modal = struct('fmax', ${n(spec.modal.fmax)}, 'max_modes', ${n(spec.modal.max_modes)}); % keep modes below fmax [Hz], at most max_modes`,
27+ `params.fd = struct('nx', ${n(spec.fd.nx)}, 'ny', ${n(spec.fd.ny)}, 'cfl', ${n(spec.fd.cfl)}, 'duration', ${n(spec.fd.duration)}); % explicit: grid nodes, fraction of the stable time step (> 1 blows up), simulated time [s]`,
28+ `params.implicit = struct('nx', ${n(spec.implicit.nx)}, 'ny', ${n(spec.implicit.ny)}, 'duration', ${n(spec.implicit.duration)}, 'steps_per_sample', ${n(spec.implicit.steps_per_sample)}, 'theta', ${n(spec.implicit.theta)}); % implicit: grid nodes, simulated time [s], steps per output sample, theta (1/4 = trapezoidal)`,
29+ ];
30+ return lines.join("\n");
31+}
packages/core/src/index.tsadded+24−0View file
@@ -0,0 +1,24 @@
1+export * from "./spec/types";
2+export * from "./spec/presets";
3+export * from "./spec/defaults";
4+export * from "./spec/validate";
5+export * from "./methods/types";
6+export * from "./methods/registry";
7+export { MODAL_METHOD } from "./methods/modal";
8+export { FD_METHOD } from "./methods/fd";
9+export { IMPLICIT_METHOD } from "./methods/implicit";
10+export { GOVERNING_EQUATIONS, STRESS_EQUATIONS, RADIATION_EQUATIONS } from "./methods/common";
11+export * from "./generate/format";
12+export * from "./generate/params";
13+export * from "./generate/library";
14+export * from "./generate/bundle";
15+export * from "./runner/plain";
16+export * from "./runner/types";
17+export { WorkerRunner } from "./runner/workerRunner";
18+export * from "./results/types";
19+export * from "./results/parse";
20+export * from "./fields/field";
21+export * from "./fields/colormap";
22+export * from "./fields/raster";
23+export * from "./audio/buffer";
24+export * from "./cost";
packages/core/src/m/plate_compare.madded+63−0View file
@@ -0,0 +1,63 @@
1+function cmp = plate_compare(A, B)
2+% PLATE_COMPARE Compare two solutions of the same plate problem.
3+%
4+% cmp = plate_compare(A, B) takes two result structs (for example from
5+% plate_modal and plate_fd) and compares them over their common time window.
6+% B is interpolated onto the time axis of A, so A should be the solution
7+% with the coarser sampling in time if they differ.
8+%
9+% cmp.t, cmp.w_a, cmp.w_b, cmp.vm_a, cmp.vm_b aligned probe series
10+% cmp.w_rel_l2, cmp.vm_rel_l2 ||a - b|| / ||a||
11+% cmp.w_peak_a, cmp.w_peak_b, cmp.vm_peak_a, cmp.vm_peak_b
12+% cmp.f_peak_a, cmp.f_peak_b frequency of the largest spectral peak
13+% cmp.frames_rel_l2 per-frame relative field error of w, when
14+% the two results share a grid and frame times
15+% cmp.frames_t_mismatch largest difference between the frame times [s]
16+%
17+% Relative errors in stress are expected to exceed those in displacement,
18+% since stress is a second derivative of the displacement.
19+
20+T = min(A.t(end), B.t(end));
21+keep = A.t <= T;
22+t = A.t(keep);
23+w_a = A.probe.w(keep);
24+vm_a = A.probe.vm(keep);
25+w_b = interp1(B.t, B.probe.w, t);
26+vm_b = interp1(B.t, B.probe.vm, t);
27+
28+cmp.t = t;
29+cmp.w_a = w_a; cmp.w_b = w_b;
30+cmp.vm_a = vm_a; cmp.vm_b = vm_b;
31+cmp.w_rel_l2 = norm(w_a - w_b) / max(norm(w_a), eps);
32+cmp.vm_rel_l2 = norm(vm_a - vm_b) / max(norm(vm_a), eps);
33+cmp.w_peak_a = max(abs(w_a)); cmp.w_peak_b = max(abs(w_b));
34+cmp.vm_peak_a = max(vm_a); cmp.vm_peak_b = max(vm_b);
35+
36+fmax = min(A.spectrum.f(end), B.spectrum.f(end));
37+ia = A.spectrum.f <= fmax & A.spectrum.f > 0; ib = B.spectrum.f <= fmax & B.spectrum.f > 0; % skip the DC bin
38+[~, ka] = max(A.spectrum.w(ia)); fa = A.spectrum.f(ia); cmp.f_peak_a = fa(ka);
39+[~, kb] = max(B.spectrum.w(ib)); fb = B.spectrum.f(ib); cmp.f_peak_b = fb(kb);
40+
41+% Frames are compared directly when the grids match and the frame times agree to
42+% within half a sample of each solver (each snaps its frames to its own time
43+% samples, so they cannot coincide exactly).
44+tol = 0.5 * ((A.t(2) - A.t(1)) + (B.t(2) - B.t(1))) + 1e-12;
45+same_grid = isequal(size(A.frames.w), size(B.frames.w)) && ...
46+ max(abs(A.grid.x - B.grid.x)) < 1e-9 && max(abs(A.grid.y - B.grid.y)) < 1e-9 && ...
47+ max(abs(A.frames.t - B.frames.t)) <= tol;
48+cmp.frames_t_mismatch = max(abs(A.frames.t(1:min(end, numel(B.frames.t))) - B.frames.t(1:min(end, numel(A.frames.t)))));
49+if same_grid
50+ nf = numel(A.frames.t);
51+ cmp.frames_t = A.frames.t;
52+ cmp.frames_rel_l2 = zeros(1, nf);
53+ for k = 1:nf
54+ da = A.frames.w(:, :, k); db = B.frames.w(:, :, k);
55+ cmp.frames_rel_l2(k) = norm(da(:) - db(:)) / max(norm(da(:)), eps);
56+ end
57+ cmp.frames_diff = A.frames.w - B.frames.w;
58+else
59+ cmp.frames_t = [];
60+ cmp.frames_rel_l2 = [];
61+ cmp.frames_diff = [];
62+end
63+end
packages/core/src/m/plate_curvature.madded+12−0View file
@@ -0,0 +1,12 @@
1+function [wxx, wyy, wxy] = plate_curvature(wp, dx, dy)
2+% PLATE_CURVATURE Second derivatives of a padded displacement field.
3+%
4+% [wxx, wyy, wxy] = plate_curvature(wp, dx, dy) returns second-order central
5+% differences at every node of the original ny x nx grid, given the padded
6+% (ny+2) x (nx+2) field wp from plate_pad. Rows index y, columns index x.
7+
8+c = wp(2:end - 1, 2:end - 1);
9+wxx = (wp(2:end - 1, 3:end) - 2 * c + wp(2:end - 1, 1:end - 2)) / dx^2;
10+wyy = (wp(3:end, 2:end - 1) - 2 * c + wp(1:end - 2, 2:end - 1)) / dy^2;
11+wxy = (wp(3:end, 3:end) - wp(3:end, 1:end - 2) - wp(1:end - 2, 3:end) + wp(1:end - 2, 1:end - 2)) / (4 * dx * dy);
12+end
packages/core/src/m/plate_fd.madded+150−0View file
@@ -0,0 +1,150 @@
1+function result = plate_fd(params)
2+% PLATE_FD Explicit finite differences for the damped Kirchhoff-Love plate.
3+%
4+% result = plate_fd(params) advances
5+%
6+% w_tt + 2 sigma0 w_t - 2 sigma1 lap(w_t) + kappa^2 lap(lap(w)) = F(t) s(x,y) / mu
7+%
8+% on a uniform nx x ny grid (nodes include the edges) with the scheme
9+%
10+% (1 + sigma0 dt) w+ = 2 w - (1 - sigma0 dt) w- - dt^2 kappa^2 L(L w)
11+% + 2 sigma1 dt (L w - L w-) + dt^2 F s / mu
12+%
13+% where w-, w, w+ are consecutive time levels and L is the five-point
14+% Laplacian. Applying L twice gives the 13-point biharmonic stencil. The
15+% scheme is second order in space and time (Bilbao, Numerical Sound
16+% Synthesis, 2009, ch. 12). Edge conditions enter through one layer of ghost
17+% nodes (plate_pad): simply supported or clamped.
18+%
19+% The step is dt = cfl * dt_max with dt_max from plate_fd_plan; the scheme
20+% is unstable for cfl > 1 and the run stops as soon as the field diverges,
21+% reporting the step at which it did (info.diverged_step).
22+%
23+% Approximations. Second-order truncation error in dx, dy, dt; the probe is
24+% snapped to the nearest grid node; stress fields are evaluated only at the
25+% saved frames, so the envelope is a sample of the true peak; the audio is
26+% the Rayleigh integral box-averaged onto audio samples.
27+%
28+% See plate_result_schema for the fields of result.
29+
30+t_start = tic;
31+P = plate_setup(params);
32+plan = plate_fd_plan(params);
33+nx = plan.nx; ny = plan.ny; dx = plan.dx; dy = plan.dy; dt = plan.dt; nsteps = plan.nsteps;
34+x = linspace(0, P.plate.Lx, nx);
35+y = linspace(0, P.plate.Ly, ny);
36+[X, Y] = meshgrid(x, y); % w(i, j) lives at (x(j), y(i))
37+fs = P.output.fs;
38+notes = {};
39+if P.fd.cfl > 1
40+ notes{end + 1} = sprintf('cfl = %g exceeds the stability limit; expect the solution to blow up', P.fd.cfl);
41+end
42+
43+s = plate_force_footprint(P.excitation, X, Y, dx * dy);
44+tt = (0:nsteps) * dt;
45+F = plate_force_time(P.excitation, tt);
46+
47+% Probe: nearest interior node
48+[~, jp] = min(abs(x - P.probe.x)); jp = min(max(jp, 2), nx - 1);
49+[~, ip] = min(abs(y - P.probe.y)); ip = min(max(ip, 2), ny - 1);
50+if abs(x(jp) - P.probe.x) > 0.5 * dx || abs(y(ip) - P.probe.y) > 0.5 * dy
51+ notes{end + 1} = sprintf('probe moved to the nearest interior node (%.4g, %.4g) m', x(jp), y(ip));
52+end
53+
54+% Frames at the steps nearest to nframes equally spaced times in [0, duration]
55+frame_steps = unique(max(1, min(nsteps, round(linspace(0, P.fd.duration, P.output.nframes) / dt))));
56+nf = numel(frame_steps);
57+Wf = zeros(ny, nx, nf); VMf = zeros(ny, nx, nf); SPf = zeros(ny, nx, nf);
58+tf = zeros(1, nf);
59+
60+% Listener: every grid node is a patch; the acceleration of a step is added into
61+% the audio sample that contains it, delayed by the node's travel time.
62+L = plate_listener(P.listener, X, Y, dx * dy, fs);
63+bins = L.delay(:) + 1;
64+wgt = L.weight(:);
65+na = ceil(nsteps * dt * fs) + L.nbins + 1;
66+Pbins = zeros(L.nbins, na);
67+
68+% State
69+w = zeros(ny, nx); wm = zeros(ny, nx); Lw_m = zeros(ny, nx);
70+w_p = zeros(1, nsteps + 1); wxx_p = zeros(1, nsteps + 1); wyy_p = zeros(1, nsteps + 1); wxy_p = zeros(1, nsteps + 1);
71+c_old = 1 - P.damping.sigma0 * dt;
72+c_new = 1 / (1 + P.damping.sigma0 * dt);
73+k2dt2 = P.kappa^2 * dt^2;
74+s1dt2 = 2 * P.damping.sigma1 * dt;
75+fdt2 = dt^2 / P.mu;
76+blowup = 1e3 * P.plate.h; % deflection beyond which the run is declared unstable
77+diverged_step = 0;
78+kf = 1;
79+
80+for nstep = 1:nsteps
81+ Lw = plate_laplacian(plate_pad(w, P.bc), dx, dy);
82+ B = zeros(ny, nx);
83+ B(2:end - 1, 2:end - 1) = (Lw(3:end, 2:end - 1) - 2 * Lw(2:end - 1, 2:end - 1) + Lw(1:end - 2, 2:end - 1)) / dy^2 ...
84+ + (Lw(2:end - 1, 3:end) - 2 * Lw(2:end - 1, 2:end - 1) + Lw(2:end - 1, 1:end - 2)) / dx^2;
85+ wn = c_new * (2 * w - c_old * wm - k2dt2 * B + s1dt2 * (Lw - Lw_m) + fdt2 * F(nstep) * s);
86+ wn(1, :) = 0; wn(end, :) = 0; wn(:, 1) = 0; wn(:, end) = 0;
87+
88+ % Probe displacement and curvatures from the 3 x 3 neighbourhood
89+ w_p(nstep + 1) = wn(ip, jp);
90+ wxx_p(nstep + 1) = (wn(ip, jp + 1) - 2 * wn(ip, jp) + wn(ip, jp - 1)) / dx^2;
91+ wyy_p(nstep + 1) = (wn(ip + 1, jp) - 2 * wn(ip, jp) + wn(ip - 1, jp)) / dy^2;
92+ wxy_p(nstep + 1) = (wn(ip + 1, jp + 1) - wn(ip + 1, jp - 1) - wn(ip - 1, jp + 1) + wn(ip - 1, jp - 1)) / (4 * dx * dy);
93+
94+ % Radiated pressure: acceleration at this step, weighted and delayed per node
95+ acc = (wn - 2 * w + wm) / dt^2;
96+ ia = floor(nstep * dt * fs) + 1;
97+ Pbins(:, ia) = Pbins(:, ia) + accumarray(bins, wgt .* acc(:), [L.nbins 1]) * (dt * fs);
98+
99+ if kf <= nf && nstep == frame_steps(kf)
100+ [wxx, wyy, wxy] = plate_curvature(plate_pad(wn, P.bc), dx, dy);
101+ S = plate_stress(P, wxx, wyy, wxy);
102+ Wf(:, :, kf) = wn; VMf(:, :, kf) = S.vm; SPf(:, :, kf) = S.sp; tf(kf) = nstep * dt;
103+ kf = kf + 1;
104+ end
105+
106+ peak = max(abs(wn(:)));
107+ if ~(peak < blowup) % also catches NaN and Inf
108+ diverged_step = nstep;
109+ notes{end + 1} = sprintf('solution diverged at step %d (t = %.3g s); results are truncated there', nstep, nstep * dt);
110+ break
111+ end
112+ wm = w; w = wn; Lw_m = Lw;
113+end
114+
115+% Truncate after a blow-up so the outputs stay finite
116+if diverged_step > 0
117+ nkeep = diverged_step; % keep the last finite level
118+ tt = tt(1:nkeep); w_p = w_p(1:nkeep); wxx_p = wxx_p(1:nkeep); wyy_p = wyy_p(1:nkeep); wxy_p = wxy_p(1:nkeep);
119+ keepf = frame_steps < diverged_step;
120+ Wf = Wf(:, :, keepf); VMf = VMf(:, :, keepf); SPf = SPf(:, :, keepf); tf = tf(keepf);
121+end
122+
123+% Audio: sum the delay bins, then trim to the simulated duration
124+p_audio = zeros(1, na);
125+for d = 1:L.nbins
126+ p_audio(d:end) = p_audio(d:end) + Pbins(d, 1:na - d + 1);
127+end
128+n_audio = min(na, ceil(numel(tt) * dt * fs));
129+p_audio = p_audio(1:n_audio);
130+
131+Sp = plate_stress(P, wxx_p, wyy_p, wxy_p);
132+[fw, Aw] = plate_spectrum(tt, w_p);
133+[~, Avm] = plate_spectrum(tt, Sp.vm);
134+
135+result.method = 'fd';
136+result.grid = struct('x', x, 'y', y);
137+result.t = tt;
138+result.probe = struct('x', x(jp), 'y', y(ip), 'w', w_p, 'sxx', Sp.sxx, 'syy', Sp.syy, 'txy', Sp.txy, 'vm', Sp.vm, 'sp', Sp.sp);
139+result.frames = struct('t', tf, 'w', Wf, 'vm', VMf, 'sp', SPf);
140+if isempty(tf)
141+ result.envelope = struct('vm', zeros(ny, nx), 'sp', zeros(ny, nx));
142+else
143+ result.envelope = struct('vm', max(VMf, [], 3), 'sp', max(abs(SPf), [], 3));
144+end
145+result.spectrum = struct('f', fw, 'w', Aw, 'vm', Avm);
146+result.audio = struct('fs', fs, 'p', p_audio, 't0', L.t0);
147+result.info = struct('nx', nx, 'ny', ny, 'dx', dx, 'dy', dy, 'dt', dt, 'dt_max', plan.dt_max, 'cfl', P.fd.cfl, ...
148+ 'nsteps', nsteps, 'ncells', nx * ny, 'cell_steps', plan.cell_steps, 'fs', fs, ...
149+ 'stable', diverged_step == 0, 'diverged_step', diverged_step, 'wall_s', toc(t_start), 'notes', {notes});
150+end
packages/core/src/m/plate_fd_plan.madded+37−0View file
@@ -0,0 +1,37 @@
1+function plan = plate_fd_plan(params)
2+% PLATE_FD_PLAN Grid, time step and cost of the explicit finite-difference scheme.
3+%
4+% plan = plate_fd_plan(params) returns
5+% plan.nx, plan.ny, plan.dx, plan.dy grid (nodes include the edges)
6+% plan.dt_max largest stable time step [s]
7+% plan.dt step actually used, params.fd.cfl * dt_max
8+% plan.nsteps, plan.ncells, plan.cell_steps problem size
9+%
10+% Stability. Von Neumann analysis of the scheme in plate_fd for a Fourier
11+% mode on which the discrete Laplacian has eigenvalue -lambda gives the
12+% amplification polynomial
13+% (1 + sigma0 dt) z^2 - (2 - dt^2 kappa^2 lambda^2 - 2 sigma1 dt lambda) z
14+% + (1 - sigma0 dt - 2 sigma1 dt lambda) = 0.
15+% Both roots stay inside the unit circle for every lambda up to the largest
16+% eigenvalue of the five-point Laplacian, lambda_max = 4/dx^2 + 4/dy^2, iff
17+% dt <= 2 / ( lambda_max (sigma1 + sqrt(kappa^2 + sigma1^2)) ),
18+% which for a square grid without sigma1 is the familiar dt <= dx^2 / (4 kappa).
19+% Because dt scales with dx^2, halving the grid spacing costs eight times
20+% as many cell updates for the same simulated time.
21+
22+P = plate_setup(params);
23+plan.nx = P.fd.nx;
24+plan.ny = P.fd.ny;
25+if plan.nx < 5 || plan.ny < 5
26+ error('plate_fd_plan: the grid needs at least 5 nodes in each direction');
27+end
28+plan.dx = P.plate.Lx / (plan.nx - 1);
29+plan.dy = P.plate.Ly / (plan.ny - 1);
30+lambda_max = 4 / plan.dx^2 + 4 / plan.dy^2;
31+s1 = P.damping.sigma1;
32+plan.dt_max = 2 / (lambda_max * (s1 + sqrt(P.kappa^2 + s1^2)));
33+plan.dt = P.fd.cfl * plan.dt_max;
34+plan.nsteps = ceil(P.fd.duration / plan.dt);
35+plan.ncells = plan.nx * plan.ny;
36+plan.cell_steps = plan.ncells * plan.nsteps;
37+end
packages/core/src/m/plate_force_footprint.madded+23−0View file
@@ -0,0 +1,23 @@
1+function s = plate_force_footprint(exc, X, Y, dA)
2+% PLATE_FORCE_FOOTPRINT Spatial distribution s(x,y) [1/m^2] of the applied force.
3+%
4+% s = plate_force_footprint(exc, X, Y, dA) evaluates a raised-cosine bump of
5+% radius exc.radius centred on the strike point (exc.x, exc.y) at the grid
6+% points X, Y (each with area dA) and scales it so that sum(s(:)) * dA = 1.
7+% The pressure is then f = F(t) s(x,y) and F(t) is the total force.
8+%
9+% A finite footprint stands in for the contact patch of a hammer. It also
10+% keeps the stress finite: a true point load gives a logarithmic stress
11+% singularity in thin-plate theory, so the stress at the strike point would
12+% depend only on how finely the load is resolved.
13+
14+r = sqrt((X - exc.x).^2 + (Y - exc.y).^2);
15+s = zeros(size(X));
16+in = r < exc.radius;
17+s(in) = 0.5 * (1 + cos(pi * r(in) / exc.radius));
18+total = sum(s(:)) * dA;
19+if total <= 0
20+ error('plate_force_footprint: no grid point lies inside the footprint (radius %g m); enlarge the radius or refine the grid', exc.radius);
21+end
22+s = s / total;
23+end
packages/core/src/m/plate_force_time.madded+23−0View file
@@ -0,0 +1,23 @@
1+function F = plate_force_time(exc, t)
2+% PLATE_FORCE_TIME Total applied force F(t) [N] sampled at the times t [s].
3+%
4+% The applied pressure is f(x,y,t) = F(t) s(x,y), where the footprint s
5+% integrates to one over the plate (see plate_force_footprint), so F(t) is
6+% the total force on the plate at time t.
7+%
8+% exc.type = 'impulse' half-sine hammer pulse: peak force exc.force [N]
9+% over the contact time exc.duration [s]
10+% exc.type = 'harmonic' sinusoidal drive exc.force * sin(2 pi exc.frequency t)
11+% switched on at t = 0
12+
13+switch exc.type
14+ case 'impulse'
15+ F = zeros(size(t));
16+ in = (t >= 0) & (t < exc.duration);
17+ F(in) = exc.force * sin(pi * t(in) / exc.duration);
18+ case 'harmonic'
19+ F = exc.force * sin(2 * pi * exc.frequency * t) .* (t >= 0);
20+ otherwise
21+ error('plate_force_time: unknown excitation type "%s" (expected impulse or harmonic)', exc.type);
22+end
23+end
packages/core/src/m/plate_implicit.madded+159−0View file
@@ -0,0 +1,159 @@
1+function result = plate_implicit(params)
2+% PLATE_IMPLICIT Implicit finite differences (theta scheme) for the damped Kirchhoff-Love plate.
3+%
4+% result = plate_implicit(params) advances
5+%
6+% w_tt + 2 sigma0 w_t - 2 sigma1 lap(w_t) + kappa^2 lap(lap(w)) = F(t) s(x,y) / mu
7+%
8+% on the same grid and with the same spatial stencils as plate_fd, but the
9+% stiffness term is averaged over three time levels,
10+%
11+% (w+ - 2w + w-)/dt^2 + sigma0 (w+ - w-)/dt - sigma1 L (w+ - w-)/dt
12+% + kappa^2 B (theta w+ + (1 - 2 theta) w + theta w-) = F s / mu,
13+%
14+% with L and B the sparse Laplacian and biharmonic matrices on the interior
15+% nodes (plate_matrices). Multiplying by dt^2 and collecting terms gives one
16+% linear system per step with a constant matrix,
17+%
18+% A w+ = C w - A_old w- + dt^2 F s / mu
19+% A = (1 + sigma0 dt) I - sigma1 dt L + theta kappa^2 dt^2 B
20+% C = 2 I - (1 - 2 theta) kappa^2 dt^2 B
21+% A_old = (1 - sigma0 dt) I + sigma1 dt L + theta kappa^2 dt^2 B.
22+%
23+% A is symmetric positive definite, so it is factored once by sparse Cholesky
24+% and each step costs two sparse triangular solves plus two sparse products.
25+% (R' is formed once; forming it every step would cost more than the solves.)
26+% For theta >= 1/4 the scheme is unconditionally stable (von Neumann analysis
27+% of the amplification polynomial); theta = 1/4 is the trapezoidal rule. The
28+% step is therefore chosen for accuracy, dt = 1 / (fs steps_per_sample).
29+%
30+% Approximations. Second order in space and time. Implicit averaging
31+% lengthens the period of a mode by about (omega dt)^2 / 12, so the highest
32+% modes are detuned rather than unstable. Probe, frames, stress envelope and
33+% audio follow the conventions of plate_fd (see plate_result_schema).
34+
35+t_start = tic;
36+P = plate_setup(params);
37+plan = plate_implicit_plan(params);
38+nx = plan.nx; ny = plan.ny; dx = plan.dx; dy = plan.dy; dt = plan.dt; nsteps = plan.nsteps;
39+theta = P.implicit.theta;
40+x = linspace(0, P.plate.Lx, nx);
41+y = linspace(0, P.plate.Ly, ny);
42+[X, Y] = meshgrid(x, y);
43+fs = P.output.fs;
44+notes = {};
45+if theta < 0.25
46+ notes{end + 1} = sprintf('theta = %g is below 1/4, so the scheme is only conditionally stable', theta);
47+end
48+
49+% ---- Matrices on the interior nodes, factored once
50+[L, B, mx, my] = plate_matrices(nx, ny, dx, dy, P.bc);
51+m = mx * my;
52+I = speye(m);
53+k2dt2 = P.kappa^2 * dt^2;
54+A = (1 + P.damping.sigma0 * dt) * I - P.damping.sigma1 * dt * L + theta * k2dt2 * B;
55+C = 2 * I - (1 - 2 * theta) * k2dt2 * B;
56+A_old = (1 - P.damping.sigma0 * dt) * I + P.damping.sigma1 * dt * L + theta * k2dt2 * B;
57+t_factor = tic;
58+R = chol(A); % A = R' * R
59+Rt = R'; % transposed once, not once per step
60+factor_s = toc(t_factor);
61+
62+% ---- Load, probe, frames, listener (as in plate_fd)
63+s = plate_force_footprint(P.excitation, X, Y, dx * dy);
64+s_int = s(2:end - 1, 2:end - 1);
65+load = (dt^2 / P.mu) * s_int(:);
66+tt = (0:nsteps) * dt;
67+F = plate_force_time(P.excitation, tt);
68+
69+[~, jp] = min(abs(x - P.probe.x)); jp = min(max(jp, 2), nx - 1);
70+[~, ip] = min(abs(y - P.probe.y)); ip = min(max(ip, 2), ny - 1);
71+if abs(x(jp) - P.probe.x) > 0.5 * dx || abs(y(ip) - P.probe.y) > 0.5 * dy
72+ notes{end + 1} = sprintf('probe moved to the nearest interior node (%.4g, %.4g) m', x(jp), y(ip));
73+end
74+
75+frame_steps = unique(max(1, min(nsteps, round(linspace(0, P.implicit.duration, P.output.nframes) / dt))));
76+nf = numel(frame_steps);
77+Wf = zeros(ny, nx, nf); VMf = zeros(ny, nx, nf); SPf = zeros(ny, nx, nf);
78+tf = zeros(1, nf);
79+
80+Lis = plate_listener(P.listener, X, Y, dx * dy, fs);
81+bins = Lis.delay(:) + 1;
82+wgt = Lis.weight(:);
83+na = ceil(nsteps * dt * fs) + Lis.nbins + 1;
84+Pbins = zeros(Lis.nbins, na);
85+
86+% ---- Time stepping on the interior vector
87+w = zeros(m, 1); wm = zeros(m, 1);
88+wfull = zeros(ny, nx);
89+w_p = zeros(1, nsteps + 1); wxx_p = zeros(1, nsteps + 1); wyy_p = zeros(1, nsteps + 1); wxy_p = zeros(1, nsteps + 1);
90+blowup = 1e3 * P.plate.h;
91+diverged_step = 0;
92+kf = 1;
93+
94+for nstep = 1:nsteps
95+ rhs = C * w - A_old * wm + F(nstep) * load;
96+ wn = R \ (Rt \ rhs); % forward then back substitution
97+
98+ wfull(2:end - 1, 2:end - 1) = reshape(wn, my, mx);
99+ w_p(nstep + 1) = wfull(ip, jp);
100+ wxx_p(nstep + 1) = (wfull(ip, jp + 1) - 2 * wfull(ip, jp) + wfull(ip, jp - 1)) / dx^2;
101+ wyy_p(nstep + 1) = (wfull(ip + 1, jp) - 2 * wfull(ip, jp) + wfull(ip - 1, jp)) / dy^2;
102+ wxy_p(nstep + 1) = (wfull(ip + 1, jp + 1) - wfull(ip + 1, jp - 1) - wfull(ip - 1, jp + 1) + wfull(ip - 1, jp - 1)) / (4 * dx * dy);
103+
104+ acc = zeros(ny, nx);
105+ acc(2:end - 1, 2:end - 1) = reshape((wn - 2 * w + wm) / dt^2, my, mx);
106+ ia = floor(nstep * dt * fs) + 1;
107+ Pbins(:, ia) = Pbins(:, ia) + accumarray(bins, wgt .* acc(:), [Lis.nbins 1]) * (dt * fs);
108+
109+ if kf <= nf && nstep == frame_steps(kf)
110+ [wxx, wyy, wxy] = plate_curvature(plate_pad(wfull, P.bc), dx, dy);
111+ S = plate_stress(P, wxx, wyy, wxy);
112+ Wf(:, :, kf) = wfull; VMf(:, :, kf) = S.vm; SPf(:, :, kf) = S.sp; tf(kf) = nstep * dt;
113+ kf = kf + 1;
114+ end
115+
116+ peak = max(abs(wn));
117+ if ~(peak < blowup)
118+ diverged_step = nstep;
119+ notes{end + 1} = sprintf('solution diverged at step %d (t = %.3g s); results are truncated there', nstep, nstep * dt);
120+ break
121+ end
122+ wm = w; w = wn;
123+end
124+
125+if diverged_step > 0
126+ nkeep = diverged_step;
127+ tt = tt(1:nkeep); w_p = w_p(1:nkeep); wxx_p = wxx_p(1:nkeep); wyy_p = wyy_p(1:nkeep); wxy_p = wxy_p(1:nkeep);
128+ keepf = frame_steps < diverged_step;
129+ Wf = Wf(:, :, keepf); VMf = VMf(:, :, keepf); SPf = SPf(:, :, keepf); tf = tf(keepf);
130+end
131+
132+p_audio = zeros(1, na);
133+for d = 1:Lis.nbins
134+ p_audio(d:end) = p_audio(d:end) + Pbins(d, 1:na - d + 1);
135+end
136+n_audio = min(na, ceil(numel(tt) * dt * fs));
137+p_audio = p_audio(1:n_audio);
138+
139+Sp = plate_stress(P, wxx_p, wyy_p, wxy_p);
140+[fw, Aw] = plate_spectrum(tt, w_p);
141+[~, Avm] = plate_spectrum(tt, Sp.vm);
142+
143+result.method = 'implicit';
144+result.grid = struct('x', x, 'y', y);
145+result.t = tt;
146+result.probe = struct('x', x(jp), 'y', y(ip), 'w', w_p, 'sxx', Sp.sxx, 'syy', Sp.syy, 'txy', Sp.txy, 'vm', Sp.vm, 'sp', Sp.sp);
147+result.frames = struct('t', tf, 'w', Wf, 'vm', VMf, 'sp', SPf);
148+if isempty(tf)
149+ result.envelope = struct('vm', zeros(ny, nx), 'sp', zeros(ny, nx));
150+else
151+ result.envelope = struct('vm', max(VMf, [], 3), 'sp', max(abs(SPf), [], 3));
152+end
153+result.spectrum = struct('f', fw, 'w', Aw, 'vm', Avm);
154+result.audio = struct('fs', fs, 'p', p_audio, 't0', Lis.t0);
155+result.info = struct('nx', nx, 'ny', ny, 'dx', dx, 'dy', dy, 'dt', dt, 'theta', theta, ...
156+ 'steps_per_sample', P.implicit.steps_per_sample, 'nunknowns', m, 'nnz_factor', nnz(R), 'factor_s', factor_s, ...
157+ 'nsteps', nsteps, 'ncells', nx * ny, 'cell_steps', plan.cell_steps, 'fs', fs, ...
158+ 'stable', diverged_step == 0, 'diverged_step', diverged_step, 'wall_s', toc(t_start), 'notes', {notes});
159+end
packages/core/src/m/plate_implicit_plan.madded+27−0View file
@@ -0,0 +1,27 @@
1+function plan = plate_implicit_plan(params)
2+% PLATE_IMPLICIT_PLAN Grid, time step and size of the implicit finite-difference scheme.
3+%
4+% plan = plate_implicit_plan(params) returns nx, ny, dx, dy, dt, nsteps,
5+% ncells, nunknowns and cell_steps for plate_implicit. There is no stability
6+% bound for theta >= 1/4; the step is set for accuracy as
7+% dt = 1 / (fs * steps_per_sample)
8+% and the trade-off is period elongation: for theta = 1/4 a mode of angular
9+% frequency omega runs slow by a factor of about 1 + (omega dt)^2 / 12. At one
10+% step per audio sample a 10 kHz mode is detuned by about 17 percent; at two
11+% steps per sample by about 4 percent.
12+
13+P = plate_setup(params);
14+plan.nx = P.implicit.nx;
15+plan.ny = P.implicit.ny;
16+if plan.nx < 5 || plan.ny < 5
17+ error('plate_implicit_plan: the grid needs at least 5 nodes in each direction');
18+end
19+plan.dx = P.plate.Lx / (plan.nx - 1);
20+plan.dy = P.plate.Ly / (plan.ny - 1);
21+plan.dt = 1 / (P.output.fs * P.implicit.steps_per_sample);
22+plan.nsteps = ceil(P.implicit.duration / plan.dt);
23+plan.ncells = plan.nx * plan.ny;
24+plan.nunknowns = (plan.nx - 2) * (plan.ny - 2);
25+plan.cell_steps = plan.ncells * plan.nsteps;
26+plan.theta = P.implicit.theta;
27+end
packages/core/src/m/plate_laplacian.madded+11−0View file
@@ -0,0 +1,11 @@
1+function L = plate_laplacian(wp, dx, dy)
2+% PLATE_LAPLACIAN Five-point Laplacian of a padded field.
3+%
4+% L = plate_laplacian(wp, dx, dy) takes an (ny+2) x (nx+2) array with one
5+% ghost layer (see plate_pad) and returns the ny x nx second-order
6+% approximation of w_xx + w_yy at every node of the original grid.
7+% Rows index y and columns index x.
8+
9+L = (wp(3:end, 2:end - 1) - 2 * wp(2:end - 1, 2:end - 1) + wp(1:end - 2, 2:end - 1)) / dy^2 ...
10+ + (wp(2:end - 1, 3:end) - 2 * wp(2:end - 1, 2:end - 1) + wp(2:end - 1, 1:end - 2)) / dx^2;
11+end
packages/core/src/m/plate_listener.madded+29−0View file
@@ -0,0 +1,29 @@
1+function L = plate_listener(lis, Xc, Yc, dA, fs)
2+% PLATE_LISTENER Geometry of the Rayleigh integral from the plate to a listener.
3+%
4+% L = plate_listener(lis, Xc, Yc, dA, fs) prepares the sound-radiation
5+% calculation for surface patches centred at (Xc, Yc) with area dA and a
6+% listener at (lis.x, lis.y, lis.z) relative to the plate corner, with
7+% sound speed lis.c [m/s] and air density lis.rho_air [kg/m^3].
8+%
9+% Rayleigh's first integral gives the pressure radiated by a flat surface
10+% set in an infinite rigid baffle:
11+%
12+% p(r, t) = rho_air / (2 pi) * integral w_tt(rs, t - |r - rs| / c) / |r - rs| dA(rs)
13+%
14+% so each patch contributes its acceleration, scaled by L.weight and delayed
15+% by its travel time. Delays are rounded to whole samples at the audio rate
16+% fs and measured from the nearest patch; the common travel time L.t0 is
17+% dropped from the signal.
18+%
19+% Validity: the baffle assumption ignores diffraction around the plate edges
20+% and any enclosure, and the plate is treated as radiating into a half-space.
21+% The result is an estimate of the radiated waveform, not a calibrated level.
22+
23+R = sqrt((Xc - lis.x).^2 + (Yc - lis.y).^2 + lis.z^2);
24+L.R = R;
25+L.weight = lis.rho_air / (2 * pi) * dA ./ R; % Pa per (m/s^2) of patch acceleration
26+L.delay = round((R - min(R(:))) * fs / lis.c); % whole samples, relative to the nearest patch
27+L.t0 = min(R(:)) / lis.c; % common travel time removed
28+L.nbins = max(L.delay(:)) + 1;
29+end
packages/core/src/m/plate_matrices.madded+43−0View file
@@ -0,0 +1,43 @@
1+function [L, B, mx, my] = plate_matrices(nx, ny, dx, dy, bc)
2+% PLATE_MATRICES Sparse Laplacian and biharmonic matrices on the interior nodes.
3+%
4+% [L, B, mx, my] = plate_matrices(nx, ny, dx, dy, bc) assembles, for the
5+% mx = nx-2 by my = ny-2 interior nodes of the grid (edge nodes carry w = 0),
6+% L the five-point Laplacian (5 nonzeros per row)
7+% B the thirteen-point biharmonic (13 nonzeros per row)
8+% as sparse matrices acting on w(:) with the interior field stored as an
9+% my x mx array, so the y index runs fastest, matching w(2:end-1, 2:end-1)(:).
10+%
11+% The stencils are the same as the ghost-node scheme in plate_fd: the second
12+% difference with w = 0 at the edge is D2; the fourth difference is D2*D2 for
13+% simply supported edges (odd reflection, w_nn = 0), and for clamped edges the
14+% even reflection w_ghost = w_1 changes the end coefficient of the fourth
15+% difference from 5/dx^4 to 7/dx^4. The mixed term 2 w_xxyy never reaches a
16+% ghost node. A test in the lab checks B against the explicit stencil.
17+%
18+% Everything is built from Kronecker products so the matrices stay sparse.
19+
20+mx = nx - 2;
21+my = ny - 2;
22+ex = ones(mx, 1);
23+ey = ones(my, 1);
24+D2x = spdiags([ex -2 * ex ex], -1:1, mx, mx) / dx^2;
25+D2y = spdiags([ey -2 * ey ey], -1:1, my, my) / dy^2;
26+Ix = speye(mx);
27+Iy = speye(my);
28+
29+D4x = D2x * D2x;
30+D4y = D2y * D2y;
31+switch bc
32+ case 'simply-supported'
33+ % D2*D2 already encodes the odd reflection
34+ case 'clamped'
35+ D4x(1, 1) = 7 / dx^4; D4x(mx, mx) = 7 / dx^4;
36+ D4y(1, 1) = 7 / dy^4; D4y(my, my) = 7 / dy^4;
37+ otherwise
38+ error('plate_matrices: unknown boundary condition "%s"', bc);
39+end
40+
41+L = kron(Ix, D2y) + kron(D2x, Iy);
42+B = kron(Ix, D4y) + 2 * kron(D2x, D2y) + kron(D4x, Iy);
43+end
packages/core/src/m/plate_modal.madded+158−0View file
@@ -0,0 +1,158 @@
1+function result = plate_modal(params)
2+% PLATE_MODAL Modal superposition for a simply supported rectangular plate.
3+%
4+% result = plate_modal(params) solves the damped Kirchhoff-Love equation
5+%
6+% w_tt + 2 sigma0 w_t - 2 sigma1 lap(w_t) + kappa^2 lap(lap(w)) = F(t) s(x,y) / mu
7+%
8+% on 0 < x < a, 0 < y < b with simply supported edges (w = lap(w) = 0). For
9+% these edges the eigenfunctions are known exactly:
10+%
11+% phi_mn(x,y) = sin(m pi x / a) sin(n pi y / b)
12+% omega_mn = kappa lambda_mn, lambda_mn = (m pi / a)^2 + (n pi / b)^2
13+%
14+% Expanding w = sum_mn q_mn(t) phi_mn(x,y) decouples the plate into
15+% independent damped oscillators, one per mode,
16+%
17+% q_mn'' + 2 sigma_mn q_mn' + omega_mn^2 q_mn = G_mn F(t) / M_mn
18+%
19+% with modal mass M_mn = mu a b / 4, modal force factor G_mn = integral of
20+% s(x,y) phi_mn over the footprint, and damping rate sigma_mn = sigma0 +
21+% sigma1 lambda_mn. Each oscillator is integrated exactly for
22+% piecewise-linear forcing (plate_sdof). Stresses follow from the analytic
23+% second derivatives of the modes, and the radiated pressure from the modal
24+% accelerations through the Rayleigh integral (plate_listener).
25+%
26+% Approximations. The modal sum is truncated at params.modal.fmax and
27+% params.modal.max_modes; the force is sampled at params.output.fs; the
28+% footprint integral and the Rayleigh integral use quadrature grids. Stress
29+% converges more slowly than displacement as modes are added, and the
30+% damping model is a choice, not a measurement.
31+%
32+% See plate_result_schema for the fields of result.
33+
34+t_start = tic;
35+P = plate_setup(params);
36+if ~strcmp(P.bc, 'simply-supported')
37+ error('plate_modal: closed-form modes exist only for simply supported edges (got "%s"); use the finite-difference method for other edges', P.bc);
38+end
39+a = P.plate.Lx;
40+b = P.plate.Ly;
41+fs = P.output.fs;
42+dt = 1 / fs;
43+nt = round(P.output.duration * fs) + 1; % samples spanning [0, duration]
44+t = (0:nt - 1) * dt;
45+notes = {};
46+
47+% ---- Mode selection: every mode below fmax, lowest first, at most max_modes
48+omega_max = 2 * pi * P.modal.fmax;
49+m_hi = max(1, ceil(a / pi * sqrt(omega_max / P.kappa)));
50+n_hi = max(1, ceil(b / pi * sqrt(omega_max / P.kappa)));
51+[MM, NN] = meshgrid(1:m_hi, 1:n_hi);
52+lam_all = (MM * pi / a).^2 + (NN * pi / b).^2;
53+keep = P.kappa * lam_all <= omega_max;
54+m = MM(keep); n = NN(keep); lam = lam_all(keep);
55+[lam, order] = sort(lam);
56+m = m(order); n = n(order);
57+truncated = numel(lam) > P.modal.max_modes;
58+if truncated
59+ notes{end + 1} = sprintf('%d modes lie below %g Hz but only the lowest %d were kept', numel(lam), P.modal.fmax, P.modal.max_modes);
60+ m = m(1:P.modal.max_modes); n = n(1:P.modal.max_modes); lam = lam(1:P.modal.max_modes);
61+end
62+m = m(:); n = n(:); lam = lam(:);
63+nm = numel(lam);
64+omega = P.kappa * lam;
65+sigma = P.damping.sigma0 + P.damping.sigma1 * lam;
66+over = sigma >= omega;
67+if any(over)
68+ notes{end + 1} = sprintf('%d modes would be overdamped by this damping model; their damping was capped just below critical', sum(over));
69+ sigma(over) = 0.99 * omega(over);
70+end
71+M = P.mu * a * b / 4; % modal mass (same for every mode)
72+kx = m * pi / a; % modal wavenumbers
73+ky = n * pi / b;
74+
75+% ---- Applied force: total force F(t) and its projection G_mn onto each mode.
76+% G_mn = integral s phi_mn dA over the footprint, by midpoint quadrature on a fine local grid.
77+F = plate_force_time(P.excitation, t);
78+exc = P.excitation;
79+nq = 64;
80+qx = linspace(max(0, exc.x - exc.radius), min(a, exc.x + exc.radius), nq);
81+qy = linspace(max(0, exc.y - exc.radius), min(b, exc.y + exc.radius), nq);
82+[QX, QY] = meshgrid(qx, qy);
83+dAq = (qx(2) - qx(1)) * (qy(2) - qy(1));
84+s = plate_force_footprint(exc, QX, QY, dAq);
85+PhiQ = sin(QX(:) * kx') .* sin(QY(:) * ky'); % (nq*nq) x nm
86+G = (s(:)' * PhiQ)' * dAq; % nm x 1
87+
88+% ---- Mode shapes and their second derivatives at the probe
89+xp = P.probe.x; yp = P.probe.y;
90+phi_p = sin(kx * xp) .* sin(ky * yp);
91+phixx_p = -kx.^2 .* phi_p;
92+phiyy_p = -ky.^2 .* phi_p;
93+phixy_p = kx .* ky .* cos(kx * xp) .* cos(ky * yp);
94+
95+% ---- Display grid for animation frames
96+ngx = P.output.grid(1); ngy = P.output.grid(2);
97+gx = linspace(0, a, ngx); gy = linspace(0, b, ngy);
98+[GX, GY] = meshgrid(gx, gy);
99+Phi = sin(GX(:) * kx') .* sin(GY(:) * ky'); % (ngy*ngx) x nm
100+Phixx = -Phi .* repmat(kx'.^2, ngx * ngy, 1);
101+Phiyy = -Phi .* repmat(ky'.^2, ngx * ngy, 1);
102+Phixy = (cos(GX(:) * kx') .* cos(GY(:) * ky')) .* repmat((kx .* ky)', ngx * ngy, 1);
103+% Frames at the samples nearest to nframes equally spaced times in [0, duration]
104+frame_idx = unique(min(nt, round(linspace(0, P.output.duration, P.output.nframes) * fs) + 1));
105+nf = numel(frame_idx);
106+Qf = zeros(nm, nf);
107+
108+% ---- Listener: Rayleigh integral over a coarse patch grid. Patches are grouped by
109+% propagation delay, giving for each mode a short delay kernel W(:, k) [Pa per (m/s^2)].
110+npx = P.listener.patches(1); npy = P.listener.patches(2);
111+cx = (0.5:npx) * a / npx; cy = (0.5:npy) * b / npy;
112+[CX, CY] = meshgrid(cx, cy);
113+L = plate_listener(P.listener, CX, CY, (a / npx) * (b / npy), fs);
114+PhiL = sin(CX(:) * kx') .* sin(CY(:) * ky'); % npatch x nm
115+W = zeros(L.nbins, nm);
116+for j = 1:numel(CX)
117+ d = L.delay(j) + 1;
118+ W(d, :) = W(d, :) + L.weight(j) * PhiL(j, :);
119+end
120+
121+% ---- Integrate the modes one at a time, accumulating the probe series and the radiated pressure
122+w_p = zeros(1, nt); wxx_p = zeros(1, nt); wyy_p = zeros(1, nt); wxy_p = zeros(1, nt);
123+p_audio = zeros(1, nt);
124+for k = 1:nm
125+ pk = (G(k) / M) * F; % modal force per unit modal mass
126+ q = plate_sdof(omega(k), sigma(k), pk, dt);
127+ w_p = w_p + phi_p(k) * q;
128+ wxx_p = wxx_p + phixx_p(k) * q;
129+ wyy_p = wyy_p + phiyy_p(k) * q;
130+ wxy_p = wxy_p + phixy_p(k) * q;
131+ Qf(k, :) = q(frame_idx);
132+ % Radiated pressure. The oscillator is linear and time-invariant, so the
133+ % delay-weighted response sum_d W(d,k) q''(t - d dt) equals the acceleration
134+ % response to the delay-weighted force sum_d W(d,k) pk(t - d dt).
135+ pk_delayed = conv(pk, W(:, k)');
136+ [~, ~, acc] = plate_sdof(omega(k), sigma(k), pk_delayed(1:nt), dt);
137+ p_audio = p_audio + acc;
138+end
139+
140+% ---- Assemble outputs
141+Sp = plate_stress(P, wxx_p, wyy_p, wxy_p);
142+Wf = reshape(Phi * Qf, ngy, ngx, nf);
143+Sf = plate_stress(P, reshape(Phixx * Qf, ngy, ngx, nf), reshape(Phiyy * Qf, ngy, ngx, nf), reshape(Phixy * Qf, ngy, ngx, nf));
144+[fw, Aw] = plate_spectrum(t, w_p);
145+[~, Avm] = plate_spectrum(t, Sp.vm);
146+
147+result.method = 'modal';
148+result.grid = struct('x', gx, 'y', gy);
149+result.t = t;
150+result.probe = struct('x', xp, 'y', yp, 'w', w_p, 'sxx', Sp.sxx, 'syy', Sp.syy, 'txy', Sp.txy, 'vm', Sp.vm, 'sp', Sp.sp);
151+result.frames = struct('t', t(frame_idx), 'w', Wf, 'vm', Sf.vm, 'sp', Sf.sp);
152+result.envelope = struct('vm', max(Sf.vm, [], 3), 'sp', max(abs(Sf.sp), [], 3));
153+result.spectrum = struct('f', fw, 'w', Aw, 'vm', Avm);
154+result.audio = struct('fs', fs, 'p', p_audio, 't0', L.t0);
155+result.modes = struct('m', m', 'n', n', 'f_hz', omega' / (2 * pi), 'sigma', sigma', 'G', G', 'truncated', truncated);
156+result.info = struct('nmodes', nm, 'fmax', P.modal.fmax, 'fs', fs, 'dt', dt, 'nsteps', nt, ...
157+ 'ncells', ngx * ngy, 'stable', true, 'diverged_step', 0, 'wall_s', toc(t_start), 'notes', {notes});
158+end
packages/core/src/m/plate_pad.madded+36−0View file
@@ -0,0 +1,36 @@
1+function wp = plate_pad(w, bc)
2+% PLATE_PAD Extend a displacement field by one ghost layer according to the edge condition.
3+%
4+% wp = plate_pad(w, bc) returns the (ny+2) x (nx+2) array whose interior is w.
5+% Along each edge w = 0 is imposed on the edge nodes themselves; the ghost
6+% values encode the second condition:
7+%
8+% 'simply-supported' w = 0 and w_nn = 0 -> ghost = -(first interior value)
9+% (odd reflection: the Laplacian vanishes on the edge)
10+% 'clamped' w = 0 and w_n = 0 -> ghost = +(first interior value)
11+% (even reflection: zero slope on the edge)
12+%
13+% Finite-difference stencils evaluated on wp are then valid at every node of
14+% the original grid, including the edges.
15+
16+[ny, nx] = size(w);
17+wp = zeros(ny + 2, nx + 2);
18+wp(2:ny + 1, 2:nx + 1) = w;
19+switch bc
20+ case 'simply-supported'
21+ sgn = -1;
22+ case 'clamped'
23+ sgn = 1;
24+ otherwise
25+ error('plate_pad: unknown boundary condition "%s"', bc);
26+end
27+wp(1, 2:nx + 1) = sgn * w(2, :);
28+wp(ny + 2, 2:nx + 1) = sgn * w(ny - 1, :);
29+wp(2:ny + 1, 1) = sgn * w(:, 2);
30+wp(2:ny + 1, nx + 2) = sgn * w(:, nx - 1);
31+% Corner ghosts: reflect twice (sgn^2 = 1 for both conditions)
32+wp(1, 1) = w(2, 2);
33+wp(1, nx + 2) = w(2, nx - 1);
34+wp(ny + 2, 1) = w(ny - 1, 2);
35+wp(ny + 2, nx + 2) = w(ny - 1, nx - 1);
36+end
packages/core/src/m/plate_result_schema.madded+18−0View file
@@ -0,0 +1,18 @@
1+% PLATE_RESULT_SCHEMA Fields shared by every solver's result struct.
2+%
3+% result.method 'modal' or 'fd'
4+% result.grid x (1 x nx), y (1 x ny): display grid of the frames [m]
5+% result.t probe time axis [s]
6+% result.probe x, y (actual probe location used), and the series
7+% w [m], sxx, syy, txy, vm, sp [Pa] sampled on result.t
8+% result.frames t (1 x nf) and the fields w [m], vm, sp [Pa],
9+% each ny x nx x nf (rows index y, columns index x)
10+% result.envelope vm, sp: peak over the saved frames, ny x nx [Pa]
11+% result.spectrum f [Hz], w [m], vm [Pa]: amplitude spectra of the probe series
12+% result.audio fs [Hz], p (pressure at the listener) [Pa], t0 (dropped travel time) [s]
13+% result.info problem size, time step, wall_s (solve wall time, tic/toc inside numbl),
14+% stable, diverged_step, notes (cell array of strings about approximations
15+% that were applied to this particular run)
16+% result.modes (modal only) m, n, f_hz, sigma, G, truncated
17+%
18+% This file documents the convention and has no executable content.
packages/core/src/m/plate_sdof.madded+46−0View file
@@ -0,0 +1,46 @@
1+function [u, v, acc] = plate_sdof(omega, sigma, p, dt)
2+% PLATE_SDOF Exact response of a damped oscillator to piecewise-linear forcing.
3+%
4+% [u, v, acc] = plate_sdof(omega, sigma, p, dt) integrates
5+%
6+% u'' + 2 sigma u' + omega^2 u = p(t)
7+%
8+% from rest, where p is sampled every dt seconds and varies linearly between
9+% samples. Returns displacement u, velocity v and acceleration acc at the
10+% sample times. The update is the exact solution over each interval
11+% (Nigam and Jennings, 1969; Chopra, Dynamics of Structures, sec. 5.2), so
12+% there is no time-step stability limit and no phase error: the only
13+% approximation is the linear interpolation of p between samples.
14+%
15+% Requires sigma < omega (underdamped).
16+
17+if sigma >= omega
18+ error('plate_sdof: the oscillator must be underdamped (sigma = %g >= omega = %g)', sigma, omega);
19+end
20+wd = sqrt(omega^2 - sigma^2); % damped natural frequency
21+e = exp(-sigma * dt);
22+s = sin(wd * dt);
23+c = cos(wd * dt);
24+w2 = omega^2;
25+zr = sigma / wd; % zeta / sqrt(1 - zeta^2)
26+
27+% Coefficients of u(n+1) = A u(n) + B v(n) + C p(n) + D p(n+1)
28+% v(n+1) = A2 u(n) + B2 v(n) + C2 p(n) + D2 p(n+1)
29+A = e * (zr * s + c);
30+B = e * s / wd;
31+C = (2 * sigma / (w2 * dt) + e * (((w2 - 2 * sigma^2) / (w2 * wd * dt) - zr) * s - (1 + 2 * sigma / (w2 * dt)) * c)) / w2;
32+D = (1 - 2 * sigma / (w2 * dt) + e * ((2 * sigma^2 - w2) / (w2 * wd * dt) * s + 2 * sigma / (w2 * dt) * c)) / w2;
33+A2 = -e * w2 / wd * s;
34+B2 = e * (c - zr * s);
35+C2 = (-1 / dt + e * ((w2 / wd + sigma / (dt * wd)) * s + c / dt)) / w2;
36+D2 = (1 - e * (zr * s + c)) / (w2 * dt);
37+
38+n = numel(p);
39+u = zeros(size(p));
40+v = zeros(size(p));
41+for k = 1:n - 1
42+ u(k + 1) = A * u(k) + B * v(k) + C * p(k) + D * p(k + 1);
43+ v(k + 1) = A2 * u(k) + B2 * v(k) + C2 * p(k) + D2 * p(k + 1);
44+end
45+acc = p - 2 * sigma * v - w2 * u;
46+end
packages/core/src/m/plate_setup.madded+39−0View file
@@ -0,0 +1,39 @@
1+function P = plate_setup(params)
2+% PLATE_SETUP Derived quantities for a Kirchhoff-Love plate problem.
3+%
4+% P = plate_setup(params) returns a copy of params with the derived fields
5+% P.D bending stiffness D = E h^3 / (12 (1 - nu^2)) [N m]
6+% P.mu mass per unit area mu = rho h [kg/m^2]
7+% P.kappa stiffness parameter kappa = sqrt(D / mu) [m^2/s]
8+% P.Kb bending-stress factor at the surface z = h/2 [N/m]
9+% P.Ks shear-stress factor at the surface z = h/2 [N/m]
10+%
11+% The transverse displacement w(x,y,t) of the plate obeys
12+%
13+% w_tt + 2 sigma0 w_t - 2 sigma1 lap(w_t) + kappa^2 lap(lap(w)) = f(x,y,t) / mu
14+%
15+% where f is the applied pressure [N/m^2], sigma0 [1/s] is a frequency-independent
16+% loss rate and sigma1 [m^2/s] adds losses that grow with spatial frequency
17+% (Bilbao, Numerical Sound Synthesis, 2009, ch. 12). The surface stresses follow
18+% from the curvatures (see plate_stress).
19+
20+P = params;
21+pl = params.plate;
22+mat = params.material;
23+
24+if pl.Lx <= 0 || pl.Ly <= 0 || pl.h <= 0
25+ error('plate_setup: plate dimensions must be positive');
26+end
27+if pl.h > 0.1 * min(pl.Lx, pl.Ly)
28+ warning('plate_setup: thickness is more than a tenth of the shorter side; Kirchhoff-Love thin-plate theory is not reliable here');
29+end
30+if ~any(strcmp(params.bc, {'simply-supported', 'clamped'}))
31+ error('plate_setup: unknown boundary condition "%s" (expected simply-supported or clamped)', params.bc);
32+end
33+
34+P.D = mat.E * pl.h^3 / (12 * (1 - mat.nu^2));
35+P.mu = mat.rho * pl.h;
36+P.kappa = sqrt(P.D / P.mu);
37+P.Kb = mat.E * pl.h / (2 * (1 - mat.nu^2));
38+P.Ks = mat.E * pl.h / (2 * (1 + mat.nu));
39+end
packages/core/src/m/plate_spectrum.madded+19−0View file
@@ -0,0 +1,19 @@
1+function [f, A] = plate_spectrum(t, x)
2+% PLATE_SPECTRUM One-sided amplitude spectrum of a real, uniformly sampled signal.
3+%
4+% [f, A] = plate_spectrum(t, x) applies a Hann window and returns the
5+% amplitude A at frequencies f [Hz], scaled so that a steady sinusoid of
6+% amplitude a shows a peak of height about a. For a decaying signal the
7+% peak heights depend on the record length, so compare shapes and
8+% frequencies rather than absolute heights.
9+
10+x = x(:).';
11+N = numel(x);
12+dt = t(2) - t(1);
13+win = 0.5 * (1 - cos(2 * pi * (0:N - 1) / (N - 1)));
14+X = fft(x .* win);
15+nh = floor(N / 2) + 1;
16+X = X(1:nh);
17+A = abs(X) * 2 / sum(win);
18+f = (0:nh - 1) / (N * dt);
19+end
packages/core/src/m/plate_stress.madded+38−0View file
@@ -0,0 +1,38 @@
1+function S = plate_stress(P, wxx, wyy, wxy)
2+% PLATE_STRESS Surface stresses of a Kirchhoff-Love plate from its curvatures.
3+%
4+% S = plate_stress(P, wxx, wyy, wxy) takes the second derivatives of the
5+% displacement (arrays of any common size) and returns, at the surface z = h/2,
6+% S.sxx, S.syy, S.txy in-plane stress components [Pa]
7+% S.vm von Mises equivalent stress [Pa]
8+% S.sp principal stress of largest magnitude, signed
9+% (tension positive) [Pa]
10+%
11+% In thin-plate theory the in-plane strains vary linearly through the
12+% thickness, eps_xx = -z w_xx, eps_yy = -z w_yy, gamma_xy = -2 z w_xy, and
13+% plane stress gives
14+% sigma_xx = -E z / (1 - nu^2) (w_xx + nu w_yy)
15+% sigma_yy = -E z / (1 - nu^2) (w_yy + nu w_xx)
16+% tau_xy = -E z / (1 + nu) w_xy
17+% Stresses are largest at the surfaces z = +-h/2 and vanish at the mid-plane;
18+% the two surfaces carry equal and opposite stress. P.Kb and P.Ks are the
19+% factors E h / (2 (1 - nu^2)) and E h / (2 (1 + nu)) (see plate_setup).
20+%
21+% Because stress is a second derivative of the primary unknown, any error in
22+% the computed displacement is amplified here.
23+
24+nu = P.material.nu;
25+S.sxx = -P.Kb * (wxx + nu * wyy);
26+S.syy = -P.Kb * (wyy + nu * wxx);
27+S.txy = -P.Ks * wxy;
28+
29+S.vm = sqrt(S.sxx.^2 - S.sxx .* S.syy + S.syy.^2 + 3 * S.txy.^2);
30+
31+c = (S.sxx + S.syy) / 2;
32+r = sqrt(((S.sxx - S.syy) / 2).^2 + S.txy.^2);
33+s1 = c + r;
34+s2 = c - r;
35+S.sp = s1;
36+pick = abs(s2) > abs(s1);
37+S.sp(pick) = s2(pick);
38+end
packages/core/src/methods/common.tsadded+41−0View file
@@ -0,0 +1,41 @@
1+import type { Equation } from "./types";
2+
3+/** Equations shared by every method: the model itself and how stress follows from displacement. */
4+export const GOVERNING_EQUATIONS: Equation[] = [
5+ {
6+ tex: String.raw`\mu\,\frac{\partial^2 w}{\partial t^2} + 2\mu\sigma_0\,\frac{\partial w}{\partial t} - 2\mu\sigma_1\,\nabla^2\frac{\partial w}{\partial t} + D\,\nabla^4 w = f(x,y,t)`,
7+ caption:
8+ "Damped Kirchhoff-Love plate: transverse displacement w, mass per area mu = rho h, applied pressure f. The two loss terms are a model choice.",
9+ },
10+ {
11+ tex: String.raw`D = \frac{E h^3}{12(1-\nu^2)}, \qquad \kappa = \sqrt{D/\mu}`,
12+ caption:
13+ "Bending stiffness and the stiffness parameter kappa that sets the dispersion of bending waves.",
14+ },
15+ {
16+ tex: String.raw`f(x,y,t) = F(t)\,s(x,y), \qquad \iint s\,dA = 1`,
17+ caption:
18+ "The load is a force history F(t) spread over a raised-cosine footprint s, so F is the total force.",
19+ },
20+];
21+
22+export const STRESS_EQUATIONS: Equation[] = [
23+ {
24+ tex: String.raw`\sigma_{xx} = -\frac{E z}{1-\nu^2}\left(w_{xx} + \nu w_{yy}\right),\quad \sigma_{yy} = -\frac{E z}{1-\nu^2}\left(w_{yy} + \nu w_{xx}\right),\quad \tau_{xy} = -\frac{E z}{1+\nu}\,w_{xy}`,
25+ caption:
26+ "Surface stresses at z = h/2 from the curvatures. Stress is a second derivative of the primary unknown, so it inherits and amplifies the solution error.",
27+ },
28+ {
29+ tex: String.raw`\sigma_{vM} = \sqrt{\sigma_{xx}^2 - \sigma_{xx}\sigma_{yy} + \sigma_{yy}^2 + 3\tau_{xy}^2}, \qquad \sigma_{1,2} = \frac{\sigma_{xx}+\sigma_{yy}}{2} \pm \sqrt{\left(\frac{\sigma_{xx}-\sigma_{yy}}{2}\right)^2 + \tau_{xy}^2}`,
30+ caption:
31+ "Von Mises equivalent stress and principal stresses; the app reports the principal stress of largest magnitude with its sign.",
32+ },
33+];
34+
35+export const RADIATION_EQUATIONS: Equation[] = [
36+ {
37+ tex: String.raw`p(\mathbf r, t) = \frac{\rho_{\mathrm{air}}}{2\pi} \iint \frac{\ddot w\!\left(\mathbf r_s,\, t - |\mathbf r - \mathbf r_s|/c\right)}{|\mathbf r - \mathbf r_s|}\, dA`,
38+ caption:
39+ "Rayleigh's first integral for a flat radiator in an infinite rigid baffle. It ignores diffraction around the edges, so the waveform is an estimate, not a calibrated level.",
40+ },
41+];
packages/core/src/methods/fd.tsadded+61−0View file
@@ -0,0 +1,61 @@
1+import type { MethodDefinition } from "./types";
2+import { GOVERNING_EQUATIONS, RADIATION_EQUATIONS, STRESS_EQUATIONS } from "./common";
3+
4+export const FD_METHOD: MethodDefinition = {
5+ id: "fd",
6+ name: "Explicit finite differences",
7+ shortName: "Explicit FD",
8+ summary: "Explicit second-order time stepping of the 13-point biharmonic stencil.",
9+ solverFunction: "plate_fd",
10+ planFunction: "plate_fd_plan",
11+ supports: {
12+ bc: ["simply-supported", "clamped"],
13+ excitation: ["impulse", "harmonic"],
14+ audio: "clip",
15+ },
16+ readingOrder: [
17+ "plate_fd.m",
18+ "plate_fd_plan.m",
19+ "plate_setup.m",
20+ "plate_pad.m",
21+ "plate_laplacian.m",
22+ "plate_curvature.m",
23+ "plate_force_time.m",
24+ "plate_force_footprint.m",
25+ "plate_stress.m",
26+ "plate_listener.m",
27+ "plate_spectrum.m",
28+ "plate_result_schema.m",
29+ ],
30+ equations: [
31+ ...GOVERNING_EQUATIONS,
32+ {
33+ tex: String.raw`(1+\sigma_0\Delta t)\,w^{n+1} = 2w^n - (1-\sigma_0\Delta t)\,w^{n-1} - \kappa^2\Delta t^2\, \mathsf L\mathsf L\, w^n + 2\sigma_1\Delta t\,\mathsf L\!\left(w^n - w^{n-1}\right) + \frac{\Delta t^2}{\mu} F^n s`,
34+ caption:
35+ "Explicit scheme, second order in space and time; L is the five-point Laplacian and LL the resulting 13-point biharmonic stencil.",
36+ },
37+ {
38+ tex: String.raw`\mathsf L w_{ij} = \frac{w_{i,j+1} - 2w_{ij} + w_{i,j-1}}{\Delta x^2} + \frac{w_{i+1,j} - 2w_{ij} + w_{i-1,j}}{\Delta y^2}`,
39+ caption:
40+ "Edge conditions enter through one ghost layer: odd reflection for simply supported (w = w_nn = 0), even reflection for clamped (w = w_n = 0).",
41+ },
42+ {
43+ tex: String.raw`\Delta t \le \frac{2}{\lambda_{\max}\left(\sigma_1 + \sqrt{\kappa^2 + \sigma_1^2}\right)}, \qquad \lambda_{\max} = \frac{4}{\Delta x^2} + \frac{4}{\Delta y^2}`,
44+ caption:
45+ "Stability bound from von Neumann analysis; for a square grid without sigma1 it reads dt <= dx^2 / (4 kappa). Because dt scales with dx^2, halving the spacing costs eight times more work.",
46+ },
47+ ...STRESS_EQUATIONS,
48+ ...RADIATION_EQUATIONS,
49+ ],
50+ validity: [
51+ "Second-order truncation error in dx, dy and dt; the top modes on the grid are dispersive and their frequencies are too low.",
52+ "The probe is snapped to the nearest interior grid node.",
53+ "Stress fields are evaluated only at the saved frames, so the envelope samples the true peak.",
54+ "The time-step limit makes long simulated times expensive: cost grows as (1/dx)^4 for a fixed duration.",
55+ "Audio is a short clip covering only the simulated window, box-averaged onto audio samples.",
56+ ],
57+ durationOf: (spec) => spec.fd.duration,
58+ gridOf: (spec) => [spec.fd.nx, spec.fd.ny],
59+ settingsKey: "fd",
60+ usPerCellStep: 0.35,
61+};
packages/core/src/methods/implicit.tsadded+62−0View file
@@ -0,0 +1,62 @@
1+import type { MethodDefinition } from "./types";
2+import { GOVERNING_EQUATIONS, RADIATION_EQUATIONS, STRESS_EQUATIONS } from "./common";
3+
4+export const IMPLICIT_METHOD: MethodDefinition = {
5+ id: "implicit",
6+ name: "Implicit finite differences",
7+ shortName: "Implicit FD",
8+ summary:
9+ "Same stencils as the explicit scheme, stiffness averaged in time; one sparse factorization, no step limit.",
10+ solverFunction: "plate_implicit",
11+ planFunction: "plate_implicit_plan",
12+ supports: {
13+ bc: ["simply-supported", "clamped"],
14+ excitation: ["impulse", "harmonic"],
15+ audio: "clip",
16+ },
17+ readingOrder: [
18+ "plate_implicit.m",
19+ "plate_implicit_plan.m",
20+ "plate_matrices.m",
21+ "plate_setup.m",
22+ "plate_force_time.m",
23+ "plate_force_footprint.m",
24+ "plate_stress.m",
25+ "plate_pad.m",
26+ "plate_curvature.m",
27+ "plate_listener.m",
28+ "plate_spectrum.m",
29+ "plate_result_schema.m",
30+ ],
31+ equations: [
32+ ...GOVERNING_EQUATIONS,
33+ {
34+ tex: String.raw`\frac{w^{n+1} - 2w^n + w^{n-1}}{\Delta t^2} + \sigma_0\frac{w^{n+1} - w^{n-1}}{\Delta t} - \sigma_1 \mathsf L\frac{w^{n+1} - w^{n-1}}{\Delta t} + \kappa^2 \mathsf B\left(\theta w^{n+1} + (1-2\theta) w^n + \theta w^{n-1}\right) = \frac{F^n s}{\mu}`,
35+ caption:
36+ "Theta scheme: the biharmonic term is averaged over three time levels. L and B are the sparse Laplacian and biharmonic matrices built from the same stencils as the explicit scheme.",
37+ },
38+ {
39+ tex: String.raw`\mathbf A\, w^{n+1} = \mathbf C\, w^n - \mathbf A_{\mathrm{old}}\, w^{n-1} + \frac{\Delta t^2}{\mu} F^n s, \qquad \mathbf A = (1+\sigma_0\Delta t)\mathbf I - \sigma_1\Delta t\,\mathsf L + \theta\kappa^2\Delta t^2\,\mathsf B`,
40+ caption:
41+ "One linear system per step with a constant symmetric positive definite matrix: factor A once by sparse Cholesky, then two triangular solves per step.",
42+ },
43+ {
44+ tex: String.raw`\theta \ge \tfrac14 \;\Rightarrow\; \text{stable for every } \Delta t, \qquad \frac{T_{\text{num}}}{T} \approx 1 + \frac{(\omega\Delta t)^2}{12} \;\; (\theta = \tfrac14)`,
45+ caption:
46+ "No stability limit, so the step is chosen for accuracy. The price is period elongation: modes near half the sample rate run slow by tens of percent unless several steps per sample are used.",
47+ },
48+ ...STRESS_EQUATIONS,
49+ ...RADIATION_EQUATIONS,
50+ ],
51+ validity: [
52+ "Second-order truncation error in dx and dy, identical to the explicit scheme, since the spatial stencils are the same.",
53+ "Second order in dt but dispersive: high modes are detuned rather than unstable. Two steps per sample keeps a 10 kHz mode within about 4 percent.",
54+ "The probe is snapped to the nearest interior grid node; stress fields are evaluated only at the saved frames.",
55+ "Cost per step is a sparse triangular solve pair, about a millisecond for a 61 x 41 grid in the browser, independent of the plate stiffness.",
56+ "Audio covers only the simulated window.",
57+ ],
58+ durationOf: (spec) => spec.implicit.duration,
59+ gridOf: (spec) => [spec.implicit.nx, spec.implicit.ny],
60+ settingsKey: "implicit",
61+ usPerCellStep: 1.0,
62+};
packages/core/src/methods/modal.tsadded+51−0View file
@@ -0,0 +1,51 @@
1+import type { MethodDefinition } from "./types";
2+import { GOVERNING_EQUATIONS, RADIATION_EQUATIONS, STRESS_EQUATIONS } from "./common";
3+
4+export const MODAL_METHOD: MethodDefinition = {
5+ id: "modal",
6+ name: "Modal superposition",
7+ shortName: "Modal",
8+ summary: "Closed-form modes of the simply supported rectangle, each integrated exactly in time.",
9+ solverFunction: "plate_modal",
10+ supports: { bc: ["simply-supported"], excitation: ["impulse", "harmonic"], audio: "full" },
11+ readingOrder: [
12+ "plate_modal.m",
13+ "plate_setup.m",
14+ "plate_force_time.m",
15+ "plate_force_footprint.m",
16+ "plate_sdof.m",
17+ "plate_stress.m",
18+ "plate_listener.m",
19+ "plate_spectrum.m",
20+ "plate_result_schema.m",
21+ ],
22+ equations: [
23+ ...GOVERNING_EQUATIONS,
24+ {
25+ tex: String.raw`\phi_{mn}(x,y) = \sin\frac{m\pi x}{a}\,\sin\frac{n\pi y}{b}, \qquad \omega_{mn} = \kappa\left[\left(\frac{m\pi}{a}\right)^2 + \left(\frac{n\pi}{b}\right)^2\right]`,
26+ caption:
27+ "Eigenfunctions and natural frequencies of the simply supported rectangle; exact for w = 0 and w_nn = 0 on all four edges.",
28+ },
29+ {
30+ tex: String.raw`w = \sum_{mn} q_{mn}(t)\,\phi_{mn}(x,y), \qquad \ddot q_{mn} + 2\sigma_{mn}\dot q_{mn} + \omega_{mn}^2 q_{mn} = \frac{G_{mn}F(t)}{M_{mn}}`,
31+ caption:
32+ "Each mode is an independent damped oscillator with modal mass M = mu a b / 4, force factor G = integral of s phi, and damping rate sigma = sigma0 + sigma1 lambda.",
33+ },
34+ {
35+ tex: String.raw`\begin{pmatrix} q_{n+1} \\ \dot q_{n+1} \end{pmatrix} = \mathbf A(\Delta t) \begin{pmatrix} q_n \\ \dot q_n \end{pmatrix} + \mathbf B(\Delta t) \begin{pmatrix} F_n \\ F_{n+1} \end{pmatrix}`,
36+ caption:
37+ "Exact state transition over one sample for a force that is linear between samples (Nigam and Jennings 1969). No stability limit and no phase error.",
38+ },
39+ ...STRESS_EQUATIONS,
40+ ...RADIATION_EQUATIONS,
41+ ],
42+ validity: [
43+ "Exact only for simply supported edges; other edge conditions have no closed-form modes.",
44+ "The modal sum is truncated at fmax and max_modes. Displacement converges quickly with mode count; stress converges more slowly, most of all near the strike point.",
45+ "The footprint projection and the Rayleigh integral are evaluated by quadrature on fixed grids.",
46+ "Damping rates are a model with two parameters, chosen, not measured.",
47+ ],
48+ durationOf: (spec) => spec.output.duration,
49+ settingsKey: "modal",
50+ usPerCellStep: 0,
51+};
packages/core/src/methods/registry.tsadded+25−0View file
@@ -0,0 +1,25 @@
1+import type { MethodDefinition } from "./types";
2+import { MODAL_METHOD } from "./modal";
3+import { FD_METHOD } from "./fd";
4+import { IMPLICIT_METHOD } from "./implicit";
5+import type { ProblemSpec } from "../spec/types";
6+
7+export const METHODS: MethodDefinition[] = [MODAL_METHOD, FD_METHOD, IMPLICIT_METHOD];
8+
9+export function getMethod(id: string): MethodDefinition {
10+ const m = METHODS.find((x) => x.id === id);
11+ if (!m) throw new Error(`unknown method ${id}`);
12+ return m;
13+}
14+
15+/** Reasons a method cannot run on a spec (empty when it can). */
16+export function methodBlockers(method: MethodDefinition, spec: ProblemSpec): string[] {
17+ const out: string[] = [];
18+ if (!method.supports.bc.includes(spec.bc))
19+ out.push(
20+ `${method.name} does not support ${spec.bc} edges (supports: ${method.supports.bc.join(", ")}).`,
21+ );
22+ if (!method.supports.excitation.includes(spec.excitation.type))
23+ out.push(`${method.name} does not support the ${spec.excitation.type} excitation.`);
24+ return out;
25+}
packages/core/src/methods/types.tsadded+45−0View file
@@ -0,0 +1,45 @@
1+import type { BoundaryCondition, ExcitationType, ProblemSpec } from "../spec/types";
2+
3+export interface Equation {
4+ /** LaTeX, display mode */
5+ tex: string;
6+ caption: string;
7+}
8+
9+export type AudioSupport = "full" | "clip" | "none";
10+
11+/**
12+ * A solution method is data: which `.m` function to call, what it can handle,
13+ * the equations it discretizes, and what it approximates. Adding a method means
14+ * adding one of these and its `.m` file; nothing else in core changes.
15+ */
16+export interface MethodDefinition {
17+ id: string;
18+ name: string;
19+ /** Two or three words, for tabs and other tight places */
20+ shortName: string;
21+ /** One sentence for tabs and tooltips */
22+ summary: string;
23+ /** Name of the `.m` function taking `params` and returning `result` */
24+ solverFunction: string;
25+ /** Optional `.m` function taking `params` and returning a cost plan */
26+ planFunction?: string;
27+ supports: {
28+ bc: BoundaryCondition[];
29+ excitation: ExcitationType[];
30+ audio: AudioSupport;
31+ };
32+ /** Library files in reading order for this method (main.m is prepended by the code view) */
33+ readingOrder: string[];
34+ equations: Equation[];
35+ /** Plain statements of what is idealized or approximate, shown next to results */
36+ validity: string[];
37+ /** Duration key of the spec that this method simulates */
38+ durationOf: (spec: ProblemSpec) => number;
39+ /** Grid the method computes on, for methods that have one; the modal method uses the display grid */
40+ gridOf?: (spec: ProblemSpec) => [number, number];
41+ /** The spec section holding this method's own settings */
42+ settingsKey: "modal" | "fd" | "implicit";
43+ /** Starting guess for wall time per cell-step in microseconds, refined by measurements */
44+ usPerCellStep: number;
45+}
packages/core/src/node.tsadded+3−0View file
@@ -0,0 +1,3 @@
1+/** Node-only entry: the in-process runner used by tests and scripts. */
2+export { NodeRunner } from "./runner/nodeRunner";
3+export { executeBundle, numblVersion } from "./runner/execute";
packages/core/src/results/parse.tsadded+172−0View file
@@ -0,0 +1,172 @@
1+import { isPlainCell, isPlainStruct, isPlainTensor, type PlainStruct, type PlainValue } from "../runner/plain";
2+import type { Field2D, FieldFrames } from "../fields/field";
3+import type { CompareResult, MethodPlan, ModeTable, PlateResult, ResultInfo } from "./types";
4+
5+export class ResultShapeError extends Error {}
6+
7+function field(s: PlainStruct, name: string): PlainValue {
8+ const v = s.fields[name];
9+ if (v === undefined) throw new ResultShapeError(`result is missing field "${name}"`);
10+ return v;
11+}
12+
13+function struct(s: PlainStruct, name: string): PlainStruct {
14+ const v = field(s, name);
15+ if (!isPlainStruct(v)) throw new ResultShapeError(`field "${name}" should be a struct`);
16+ return v;
17+}
18+
19+function num(s: PlainStruct, name: string): number {
20+ const v = field(s, name);
21+ if (typeof v === "number") return v;
22+ if (typeof v === "boolean") return v ? 1 : 0;
23+ if (isPlainTensor(v) && v.data.length === 1) return v.data[0];
24+ throw new ResultShapeError(`field "${name}" should be a scalar`);
25+}
26+
27+function bool(s: PlainStruct, name: string): boolean {
28+ return num(s, name) !== 0;
29+}
30+
31+function str(s: PlainStruct, name: string): string {
32+ const v = field(s, name);
33+ if (typeof v === "string") return v;
34+ throw new ResultShapeError(`field "${name}" should be a string`);
35+}
36+
37+function vec(s: PlainStruct, name: string): Float64Array {
38+ const v = field(s, name);
39+ if (typeof v === "number") return Float64Array.of(v);
40+ if (isPlainTensor(v)) return v.data;
41+ throw new ResultShapeError(`field "${name}" should be a numeric vector`);
42+}
43+
44+function field2d(s: PlainStruct, name: string): Field2D {
45+ const v = field(s, name);
46+ if (!isPlainTensor(v) || v.shape.length !== 2) throw new ResultShapeError(`field "${name}" should be a 2-D array`);
47+ return { ny: v.shape[0], nx: v.shape[1], data: v.data };
48+}
49+
50+function frames3d(s: PlainStruct, name: string): FieldFrames {
51+ const v = field(s, name);
52+ if (!isPlainTensor(v)) throw new ResultShapeError(`field "${name}" should be a 3-D array`);
53+ const [ny, nx, nf] = [v.shape[0] ?? 0, v.shape[1] ?? 0, v.shape[2] ?? 1];
54+ if (v.shape.length < 2 || v.shape.length > 3) throw new ResultShapeError(`field "${name}" should be a 3-D array`);
55+ return { ny, nx, nf, data: v.data };
56+}
57+
58+function strings(s: PlainStruct, name: string): string[] {
59+ const v = s.fields[name];
60+ if (v === undefined) return [];
61+ if (typeof v === "string") return [v];
62+ if (isPlainCell(v)) return v.items.map((x) => (typeof x === "string" ? x : JSON.stringify(x)));
63+ return [];
64+}
65+
66+const INFO_STANDARD = new Set(["nsteps", "ncells", "dt", "fs", "wall_s", "stable", "diverged_step", "notes"]);
67+
68+function parseInfo(s: PlainStruct): ResultInfo {
69+ const extra: Record<string, number | string | boolean> = {};
70+ for (const [k, v] of Object.entries(s.fields)) {
71+ if (INFO_STANDARD.has(k)) continue;
72+ if (typeof v === "number" || typeof v === "string" || typeof v === "boolean") extra[k] = v;
73+ else if (isPlainTensor(v) && v.data.length === 1) extra[k] = v.data[0];
74+ }
75+ return {
76+ nsteps: num(s, "nsteps"),
77+ ncells: num(s, "ncells"),
78+ dt: num(s, "dt"),
79+ fs: num(s, "fs"),
80+ wall_s: num(s, "wall_s"),
81+ stable: bool(s, "stable"),
82+ diverged_step: num(s, "diverged_step"),
83+ notes: strings(s, "notes"),
84+ extra,
85+ };
86+}
87+
88+function parseModes(s: PlainStruct): ModeTable {
89+ return {
90+ m: vec(s, "m"),
91+ n: vec(s, "n"),
92+ f_hz: vec(s, "f_hz"),
93+ sigma: vec(s, "sigma"),
94+ G: vec(s, "G"),
95+ truncated: bool(s, "truncated"),
96+ };
97+}
98+
99+/** Validate and type the solver's result struct. Throws ResultShapeError with a specific message. */
100+export function parsePlateResult(v: PlainValue): PlateResult {
101+ if (!isPlainStruct(v)) throw new ResultShapeError("the result variable is not a struct");
102+ const grid = struct(v, "grid");
103+ const probe = struct(v, "probe");
104+ const frames = struct(v, "frames");
105+ const env = struct(v, "envelope");
106+ const spec = struct(v, "spectrum");
107+ const audio = struct(v, "audio");
108+ const out: PlateResult = {
109+ method: str(v, "method"),
110+ grid: { x: vec(grid, "x"), y: vec(grid, "y") },
111+ t: vec(v, "t"),
112+ probe: {
113+ x: num(probe, "x"),
114+ y: num(probe, "y"),
115+ w: vec(probe, "w"),
116+ sxx: vec(probe, "sxx"),
117+ syy: vec(probe, "syy"),
118+ txy: vec(probe, "txy"),
119+ vm: vec(probe, "vm"),
120+ sp: vec(probe, "sp"),
121+ },
122+ frames: { t: vec(frames, "t"), w: frames3d(frames, "w"), vm: frames3d(frames, "vm"), sp: frames3d(frames, "sp") },
123+ envelope: { vm: field2d(env, "vm"), sp: field2d(env, "sp") },
124+ spectrum: { f: vec(spec, "f"), w: vec(spec, "w"), vm: vec(spec, "vm") },
125+ audio: { fs: num(audio, "fs"), p: vec(audio, "p"), t0: num(audio, "t0") },
126+ info: parseInfo(struct(v, "info")),
127+ };
128+ if (isPlainStruct(v.fields.modes)) out.modes = parseModes(v.fields.modes);
129+ return out;
130+}
131+
132+export function parseMethodPlan(v: PlainValue): MethodPlan {
133+ if (!isPlainStruct(v)) throw new ResultShapeError("the plan variable is not a struct");
134+ return {
135+ nx: num(v, "nx"),
136+ ny: num(v, "ny"),
137+ dx: num(v, "dx"),
138+ dy: num(v, "dy"),
139+ dt_max: v.fields.dt_max !== undefined ? num(v, "dt_max") : undefined,
140+ dt: num(v, "dt"),
141+ nsteps: num(v, "nsteps"),
142+ ncells: num(v, "ncells"),
143+ cell_steps: num(v, "cell_steps"),
144+ };
145+}
146+
147+export function parseCompareResult(v: PlainValue): CompareResult {
148+ if (!isPlainStruct(v)) throw new ResultShapeError("the comparison variable is not a struct");
149+ const fd = v.fields.frames_diff;
150+ const framesDiff = isPlainTensor(fd) && fd.data.length > 0 ? frames3d(v, "frames_diff") : null;
151+ return {
152+ t: vec(v, "t"),
153+ w_a: vec(v, "w_a"),
154+ w_b: vec(v, "w_b"),
155+ vm_a: vec(v, "vm_a"),
156+ vm_b: vec(v, "vm_b"),
157+ w_rel_l2: num(v, "w_rel_l2"),
158+ vm_rel_l2: num(v, "vm_rel_l2"),
159+ w_peak_a: num(v, "w_peak_a"),
160+ w_peak_b: num(v, "w_peak_b"),
161+ vm_peak_a: num(v, "vm_peak_a"),
162+ vm_peak_b: num(v, "vm_peak_b"),
163+ f_peak_a: num(v, "f_peak_a"),
164+ f_peak_b: num(v, "f_peak_b"),
165+ frames_t: vec(v, "frames_t"),
166+ frames_rel_l2: vec(v, "frames_rel_l2"),
167+ frames_diff: framesDiff,
168+ };
169+}
170+
171+/** @deprecated use parseMethodPlan */
172+export const parseFdPlan = parseMethodPlan;
packages/core/src/results/types.tsadded+84−0View file
@@ -0,0 +1,84 @@
1+import type { Field2D, FieldFrames } from "../fields/field";
2+
3+export interface ProbeSeries {
4+ x: number;
5+ y: number;
6+ w: Float64Array;
7+ sxx: Float64Array;
8+ syy: Float64Array;
9+ txy: Float64Array;
10+ vm: Float64Array;
11+ sp: Float64Array;
12+}
13+
14+export interface ResultInfo {
15+ nsteps: number;
16+ ncells: number;
17+ dt: number;
18+ fs: number;
19+ /** Wall time measured inside the .m with tic/toc [s] */
20+ wall_s: number;
21+ stable: boolean;
22+ diverged_step: number;
23+ notes: string[];
24+ /** Method-specific extras (nmodes, nx, ny, dt_max, cfl, cell_steps, ...) */
25+ extra: Record<string, number | string | boolean>;
26+}
27+
28+export interface ModeTable {
29+ m: Float64Array;
30+ n: Float64Array;
31+ f_hz: Float64Array;
32+ sigma: Float64Array;
33+ G: Float64Array;
34+ truncated: boolean;
35+}
36+
37+/** Typed view of the `result` struct every solver returns (see plate_result_schema.m). */
38+export interface PlateResult {
39+ method: string;
40+ grid: { x: Float64Array; y: Float64Array };
41+ t: Float64Array;
42+ probe: ProbeSeries;
43+ frames: { t: Float64Array; w: FieldFrames; vm: FieldFrames; sp: FieldFrames };
44+ envelope: { vm: Field2D; sp: Field2D };
45+ spectrum: { f: Float64Array; w: Float64Array; vm: Float64Array };
46+ audio: { fs: number; p: Float64Array; t0: number };
47+ info: ResultInfo;
48+ modes?: ModeTable;
49+}
50+
51+/** Cost plan of a grid-based method (plate_fd_plan, plate_implicit_plan). */
52+export interface MethodPlan {
53+ nx: number;
54+ ny: number;
55+ dx: number;
56+ dy: number;
57+ /** Largest stable step, for methods that have one */
58+ dt_max?: number;
59+ dt: number;
60+ nsteps: number;
61+ ncells: number;
62+ cell_steps: number;
63+}
64+/** @deprecated use MethodPlan */
65+export type FdPlan = MethodPlan;
66+
67+export interface CompareResult {
68+ t: Float64Array;
69+ w_a: Float64Array;
70+ w_b: Float64Array;
71+ vm_a: Float64Array;
72+ vm_b: Float64Array;
73+ w_rel_l2: number;
74+ vm_rel_l2: number;
75+ w_peak_a: number;
76+ w_peak_b: number;
77+ vm_peak_a: number;
78+ vm_peak_b: number;
79+ f_peak_a: number;
80+ f_peak_b: number;
81+ frames_t: Float64Array;
82+ frames_rel_l2: Float64Array;
83+ frames_diff: FieldFrames | null;
84+}
packages/core/src/runner/convert.tsadded+60−0View file
@@ -0,0 +1,60 @@
1+import { RTV } from "numbl";
2+import type { RuntimeValue } from "numbl";
3+import type { PlainValue } from "./plain";
4+
5+type Kinded = { kind?: string };
6+
7+/** numbl RuntimeValue -> PlainValue. Tensor data is copied so the result owns its memory. */
8+export function toPlain(v: RuntimeValue | undefined): PlainValue {
9+ if (v === undefined) return { kind: "unsupported", description: "undefined" };
10+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "string") return v;
11+ const k = (v as Kinded).kind;
12+ switch (k) {
13+ case "tensor": {
14+ const t = v as { data: Float64Array; imag?: Float64Array; shape: number[] };
15+ const out: PlainValue = { kind: "tensor", shape: [...t.shape], data: Float64Array.from(t.data) };
16+ if (t.imag) out.imag = Float64Array.from(t.imag);
17+ return out;
18+ }
19+ case "char":
20+ return (v as { value: string }).value;
21+ case "struct": {
22+ const fields: Record<string, PlainValue> = {};
23+ for (const [name, val] of (v as { fields: Map<string, RuntimeValue> }).fields) fields[name] = toPlain(val);
24+ return { kind: "struct", fields };
25+ }
26+ case "cell": {
27+ const c = v as { data: RuntimeValue[]; shape: number[] };
28+ return { kind: "cell", shape: [...c.shape], items: c.data.map(toPlain) };
29+ }
30+ case "struct_array": {
31+ // struct(...) with a cell-valued field builds a struct array in MATLAB; a single
32+ // element is the common case (e.g. 'notes', {notes}) and reads as a plain struct.
33+ const sa = v as { elements: RuntimeValue[] };
34+ if (sa.elements.length === 1) return toPlain(sa.elements[0]);
35+ return { kind: "cell", shape: [1, sa.elements.length], items: sa.elements.map(toPlain) };
36+ }
37+ case "complex":
38+ return { kind: "unsupported", description: "complex scalar" };
39+ default:
40+ return { kind: "unsupported", description: k ?? typeof v };
41+ }
42+}
43+
44+/** PlainValue -> numbl RuntimeValue, for passing results back into a script (e.g. plate_compare). */
45+export function toRuntime(v: PlainValue): RuntimeValue {
46+ if (typeof v === "number" || typeof v === "boolean" || typeof v === "string") return v;
47+ switch (v.kind) {
48+ case "tensor":
49+ return RTV.tensor(Float64Array.from(v.data), [...v.shape], v.imag ? Float64Array.from(v.imag) : undefined);
50+ case "struct": {
51+ const fields: Record<string, RuntimeValue> = {};
52+ for (const [name, val] of Object.entries(v.fields)) fields[name] = toRuntime(val);
53+ return RTV.struct(fields);
54+ }
55+ case "cell":
56+ return RTV.cell(v.items.map(toRuntime), [...v.shape]);
57+ case "unsupported":
58+ throw new Error(`cannot convert unsupported value (${v.description}) to numbl`);
59+ }
60+}
packages/core/src/runner/execute.tsadded+74−0View file
@@ -0,0 +1,74 @@
1+import { executeCode, RuntimeError } from "numbl";
2+import type { RuntimeValue } from "numbl";
3+import type { RunRequest, RunResult, BackendInfo } from "./types";
4+import { toPlain, toRuntime } from "./convert";
5+
6+const OPTIMIZATION = "1" as const;
7+
8+declare const __NUMBL_VERSION__: string | undefined;
9+
10+/**
11+ * numbl's package version, injected at build time by the host's Vite config
12+ * (see vite.shared.ts); numbl's own `version` builtin reports the MATLAB
13+ * release it emulates, which is not what "which backend ran it" means.
14+ */
15+export function numblVersion(): string {
16+ return typeof __NUMBL_VERSION__ === "string" ? __NUMBL_VERSION__ : "unknown";
17+}
18+
19+/**
20+ * Run a bundle synchronously in the current thread. Both the Node runner and
21+ * the worker call this, so the two hosts execute identically.
22+ */
23+export function executeBundle(req: RunRequest, backend: BackendInfo, onOutput?: (text: string) => void): RunResult {
24+ const { bundle } = req;
25+ const entry = bundle.files.find((f) => f.name === bundle.entry);
26+ const output: string[] = [];
27+ const t0 = performance.now();
28+ if (!entry) {
29+ return { ok: false, error: `entry file ${bundle.entry} is not in the bundle`, output, wallMs: 0, backend };
30+ }
31+ const workspaceFiles = bundle.files.filter((f) => f.name !== bundle.entry).map((f) => ({ name: f.name, source: f.source }));
32+ const initialVariableValues: Record<string, RuntimeValue> = {};
33+ for (const [name, v] of Object.entries(req.inputs ?? {})) initialVariableValues[name] = toRuntime(v);
34+ try {
35+ const result = executeCode(
36+ entry.source,
37+ {
38+ optimization: OPTIMIZATION,
39+ displayResults: false,
40+ initialVariableValues,
41+ onOutput: (text: string) => {
42+ output.push(text);
43+ onOutput?.(text);
44+ },
45+ },
46+ workspaceFiles,
47+ bundle.entry,
48+ );
49+ const wallMs = performance.now() - t0;
50+ const value = result.variableValues[bundle.resultVariable];
51+ if (value === undefined) {
52+ return {
53+ ok: false,
54+ error: `the script finished but did not define the variable "${bundle.resultVariable}"`,
55+ output,
56+ wallMs,
57+ backend,
58+ };
59+ }
60+ return { ok: true, value: toPlain(value), output, wallMs, backend };
61+ } catch (e) {
62+ const wallMs = performance.now() - t0;
63+ return { ok: false, error: formatError(e), output, wallMs, backend };
64+ }
65+}
66+
67+export function formatError(e: unknown): string {
68+ if (e instanceof RuntimeError) {
69+ const where = [e.file, e.line].filter((x) => x !== undefined && x !== null && x !== "").join(":");
70+ return where ? `${where}: ${e.message}` : e.message;
71+ }
72+ if (e instanceof Error) return e.message;
73+ return String(e);
74+}
packages/core/src/runner/nodeRunner.tsadded+18−0View file
@@ -0,0 +1,18 @@
1+import type { BackendInfo, RunRequest, RunResult, Runner } from "./types";
2+import { executeBundle, numblVersion } from "./execute";
3+
4+/** Runs numbl in the current Node process. Used by tests and scripts; cannot cancel. */
5+export class NodeRunner implements Runner {
6+ private readonly info: BackendInfo = { engine: "numbl", version: numblVersion(), optimization: "1", host: "node" };
7+ readonly backend = Promise.resolve(this.info);
8+
9+ run(req: RunRequest, onOutput?: (text: string) => void): Promise<RunResult> {
10+ return Promise.resolve(executeBundle(req, this.info, onOutput));
11+ }
12+ cancel(): void {
13+ /* synchronous host: nothing in flight between awaits */
14+ }
15+ dispose(): void {
16+ /* nothing to release */
17+ }
18+}
packages/core/src/runner/plain.tsadded+48−0View file
@@ -0,0 +1,48 @@
1+/**
2+ * Plain, structured-clone-friendly mirror of the numbl values the lab uses.
3+ * Tensors keep numbl's column-major layout: element (i, j) of an [m, n] array is
4+ * data[i + j*m], and (i, j, k) of an [m, n, p] array is data[i + j*m + k*m*n].
5+ */
6+export interface PlainTensor {
7+ kind: "tensor";
8+ shape: number[];
9+ data: Float64Array;
10+ imag?: Float64Array;
11+}
12+export interface PlainStruct {
13+ kind: "struct";
14+ fields: Record<string, PlainValue>;
15+}
16+export interface PlainCell {
17+ kind: "cell";
18+ shape: number[];
19+ items: PlainValue[];
20+}
21+export interface PlainUnsupported {
22+ kind: "unsupported";
23+ description: string;
24+}
25+export type PlainValue = number | boolean | string | PlainTensor | PlainStruct | PlainCell | PlainUnsupported;
26+
27+export function isPlainTensor(v: PlainValue | undefined): v is PlainTensor {
28+ return typeof v === "object" && v !== null && v.kind === "tensor";
29+}
30+export function isPlainStruct(v: PlainValue | undefined): v is PlainStruct {
31+ return typeof v === "object" && v !== null && v.kind === "struct";
32+}
33+export function isPlainCell(v: PlainValue | undefined): v is PlainCell {
34+ return typeof v === "object" && v !== null && v.kind === "cell";
35+}
36+
37+/** All ArrayBuffers inside a value, for zero-copy transfer out of a worker. */
38+export function collectBuffers(v: PlainValue, out: ArrayBuffer[] = []): ArrayBuffer[] {
39+ if (isPlainTensor(v)) {
40+ out.push(v.data.buffer as ArrayBuffer);
41+ if (v.imag) out.push(v.imag.buffer as ArrayBuffer);
42+ } else if (isPlainStruct(v)) {
43+ for (const f of Object.values(v.fields)) collectBuffers(f, out);
44+ } else if (isPlainCell(v)) {
45+ for (const item of v.items) collectBuffers(item, out);
46+ }
47+ return out;
48+}
packages/core/src/runner/protocol.tsadded+12−0View file
@@ -0,0 +1,12 @@
1+import type { SourceBundle } from "../generate/bundle";
2+import type { PlainValue } from "./plain";
3+import type { BackendInfo, RunResult } from "./types";
4+
5+export type ToWorker =
6+ | { type: "run"; id: number; bundle: SourceBundle; inputs?: Record<string, PlainValue> }
7+ | { type: "info"; id: number };
8+
9+export type FromWorker =
10+ | { type: "output"; id: number; text: string }
11+ | { type: "done"; id: number; result: RunResult }
12+ | { type: "info"; id: number; backend: BackendInfo };
packages/core/src/runner/types.tsadded+48−0View file
@@ -0,0 +1,48 @@
1+import type { SourceBundle } from "../generate/bundle";
2+import type { PlainValue } from "./plain";
3+
4+export interface BackendInfo {
5+ engine: "numbl";
6+ version: string;
7+ /** numbl optimization level: "1" is the JS JIT, "0" the interpreter */
8+ optimization: "0" | "1";
9+ /** Where the code ran */
10+ host: "web-worker" | "node";
11+}
12+
13+export interface RunRequest {
14+ bundle: SourceBundle;
15+ /** Workspace variables defined before the script runs */
16+ inputs?: Record<string, PlainValue>;
17+}
18+
19+export interface RunSuccess {
20+ ok: true;
21+ value: PlainValue;
22+ /** Everything the script printed */
23+ output: string[];
24+ /** Wall time of executeCode, including JIT compilation [ms] */
25+ wallMs: number;
26+ backend: BackendInfo;
27+}
28+
29+export interface RunFailure {
30+ ok: false;
31+ /** Formatted numbl error, with file and line when known */
32+ error: string;
33+ output: string[];
34+ wallMs: number;
35+ backend: BackendInfo;
36+ /** True when the run was cancelled by the host rather than failing */
37+ cancelled?: boolean;
38+}
39+
40+export type RunResult = RunSuccess | RunFailure;
41+
42+export interface Runner {
43+ run(req: RunRequest, onOutput?: (text: string) => void): Promise<RunResult>;
44+ /** Abandon the run in flight, if any. Its promise resolves with cancelled: true. */
45+ cancel(): void;
46+ dispose(): void;
47+ readonly backend: Promise<BackendInfo>;
48+}
packages/core/src/runner/worker.tsadded+27−0View file
@@ -0,0 +1,27 @@
1+/// <reference lib="webworker" />
2+/**
3+ * Web Worker that owns a numbl instance. One run at a time; the host cancels a
4+ * run by terminating the worker, which is why nothing here needs to be
5+ * interruptible.
6+ */
7+import { executeBundle, numblVersion } from "./execute";
8+import { collectBuffers } from "./plain";
9+import type { FromWorker, ToWorker } from "./protocol";
10+import type { BackendInfo } from "./types";
11+
12+const backend: BackendInfo = { engine: "numbl", version: numblVersion(), optimization: "1", host: "web-worker" };
13+
14+const post = (msg: FromWorker, transfer: ArrayBuffer[] = []) => self.postMessage(msg, transfer);
15+
16+self.onmessage = (e: MessageEvent<ToWorker>) => {
17+ const msg = e.data;
18+ if (msg.type === "info") {
19+ post({ type: "info", id: msg.id, backend });
20+ return;
21+ }
22+ const result = executeBundle({ bundle: msg.bundle, inputs: msg.inputs }, backend, (text) =>
23+ post({ type: "output", id: msg.id, text }),
24+ );
25+ const transfer = result.ok ? collectBuffers(result.value) : [];
26+ post({ type: "done", id: msg.id, result }, transfer);
27+};
packages/core/src/runner/workerRunner.tsadded+93−0View file
@@ -0,0 +1,93 @@
1+import type { BackendInfo, RunRequest, RunResult, Runner } from "./types";
2+import type { FromWorker, ToWorker } from "./protocol";
3+
4+interface Pending {
5+ resolve: (r: RunResult) => void;
6+ onOutput?: (text: string) => void;
7+ output: string[];
8+ t0: number;
9+}
10+
11+/**
12+ * Runs numbl in a Web Worker the runner owns. Cancelling terminates the worker
13+ * and a fresh one is created for the next run, so a runaway time-stepping loop
14+ * can always be stopped, with or without cross-origin isolation.
15+ */
16+export class WorkerRunner implements Runner {
17+ private worker: Worker | null = null;
18+ private nextId = 1;
19+ private pending = new Map<number, Pending>();
20+ private infoResolvers = new Map<number, (b: BackendInfo) => void>();
21+ private cachedBackend: BackendInfo | null = null;
22+ private readonly createWorker: () => Worker;
23+
24+ constructor(createWorker?: () => Worker) {
25+ this.createWorker =
26+ createWorker ?? (() => new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }));
27+ }
28+
29+ get backend(): Promise<BackendInfo> {
30+ if (this.cachedBackend) return Promise.resolve(this.cachedBackend);
31+ return new Promise((resolve) => {
32+ const id = this.nextId++;
33+ this.infoResolvers.set(id, (b) => {
34+ this.cachedBackend = b;
35+ resolve(b);
36+ });
37+ this.ensureWorker().postMessage({ type: "info", id } satisfies ToWorker);
38+ });
39+ }
40+
41+ private ensureWorker(): Worker {
42+ if (this.worker) return this.worker;
43+ const w = this.createWorker();
44+ w.onmessage = (e: MessageEvent<FromWorker>) => this.onMessage(e.data);
45+ w.onerror = (e) => this.failAll(`numbl worker crashed: ${e.message || "unknown error"}`);
46+ this.worker = w;
47+ return w;
48+ }
49+
50+ private onMessage(msg: FromWorker) {
51+ if (msg.type === "info") {
52+ this.infoResolvers.get(msg.id)?.(msg.backend);
53+ this.infoResolvers.delete(msg.id);
54+ return;
55+ }
56+ const p = this.pending.get(msg.id);
57+ if (!p) return;
58+ if (msg.type === "output") {
59+ p.output.push(msg.text);
60+ p.onOutput?.(msg.text);
61+ } else if (msg.type === "done") {
62+ this.pending.delete(msg.id);
63+ p.resolve(msg.result);
64+ }
65+ }
66+
67+ private failAll(error: string, cancelled = false) {
68+ const backend = this.cachedBackend ?? { engine: "numbl", version: "unknown", optimization: "1", host: "web-worker" };
69+ for (const p of this.pending.values()) {
70+ p.resolve({ ok: false, error, output: p.output, wallMs: performance.now() - p.t0, backend, cancelled });
71+ }
72+ this.pending.clear();
73+ }
74+
75+ run(req: RunRequest, onOutput?: (text: string) => void): Promise<RunResult> {
76+ return new Promise((resolve) => {
77+ const id = this.nextId++;
78+ this.pending.set(id, { resolve, onOutput, output: [], t0: performance.now() });
79+ this.ensureWorker().postMessage({ type: "run", id, bundle: req.bundle, inputs: req.inputs } satisfies ToWorker);
80+ });
81+ }
82+
83+ cancel(): void {
84+ if (!this.worker) return;
85+ this.worker.terminate();
86+ this.worker = null;
87+ this.failAll("run cancelled", true);
88+ }
89+
90+ dispose(): void {
91+ this.cancel();
92+ }
93+}
packages/core/src/spec/defaults.tsadded+27−0View file
@@ -0,0 +1,27 @@
1+import { MATERIAL_PRESETS, PLATE_PRESETS } from "./presets";
2+import type { ProblemSpec } from "./types";
3+
4+/** The default problem: a struck A36 steel sheet, 300 x 200 x 2 mm, simply supported. */
5+export function defaultSpec(): ProblemSpec {
6+ const mat = MATERIAL_PRESETS[0];
7+ return {
8+ plate: { ...PLATE_PRESETS[0].plate },
9+ material: { ...mat.material },
10+ bc: "simply-supported",
11+ damping: { ...mat.damping },
12+ excitation: { type: "impulse", x: 0.1, y: 0.08, force: 50, duration: 1e-3, radius: 0.012 },
13+ probe: { x: 0.2, y: 0.12 },
14+ listener: { x: 0.15, y: 0.1, z: 1.0, c: 343, rho_air: 1.2, patches: [24, 16] },
15+ output: { fs: 44100, duration: 2.0, nframes: 90, grid: [61, 41] },
16+ modal: { fmax: 20000, max_modes: 400 },
17+ // 41 x 27 keeps 20 ms of simulated time to a few seconds of solve time; a finer grid costs (dx_old/dx_new)^4 more
18+ fd: { nx: 41, ny: 27, cfl: 0.9, duration: 0.02 },
19+ // no stability limit: the finer grid and longer window cost one factorization plus a sparse solve per step
20+ implicit: { nx: 61, ny: 41, duration: 0.05, steps_per_sample: 1, theta: 0.25 },
21+ };
22+}
23+
24+/** Deep-copy a spec (specs are plain data). */
25+export function cloneSpec(spec: ProblemSpec): ProblemSpec {
26+ return JSON.parse(JSON.stringify(spec)) as ProblemSpec;
27+}
packages/core/src/spec/presets.tsadded+112−0View file
@@ -0,0 +1,112 @@
1+import type { Damping, Material, PlateGeometry } from "./types";
2+
3+/**
4+ * Material presets. Values are room-temperature handbook figures for the named
5+ * alloy (ASM Handbook, MatWeb, Copper Development Association) and are typical
6+ * rather than certified: real stock varies by a few percent in E and rho.
7+ * The damping values are illustrative starting points in the two-parameter
8+ * loss model, not measurements; damping in a real plate depends heavily on the
9+ * mounting.
10+ */
11+export interface MaterialPreset {
12+ id: string;
13+ material: Material;
14+ damping: Damping;
15+ /** Where the numbers come from and what they do not capture */
16+ source: string;
17+}
18+
19+export const MATERIAL_PRESETS: MaterialPreset[] = [
20+ {
21+ id: "a36-steel",
22+ material: { name: "A36 structural steel", E: 200e9, nu: 0.26, rho: 7850 },
23+ damping: { sigma0: 1.0, sigma1: 0.005 },
24+ source: "ASTM A36 hot-rolled plate; E and nu typical of low-carbon steel.",
25+ },
26+ {
27+ id: "304-stainless",
28+ material: { name: "304 stainless steel", E: 193e9, nu: 0.29, rho: 8000 },
29+ damping: { sigma0: 1.0, sigma1: 0.005 },
30+ source: "Annealed 304 sheet, typical values.",
31+ },
32+ {
33+ id: "6061-t6",
34+ material: { name: "6061-T6 aluminium", E: 68.9e9, nu: 0.33, rho: 2700 },
35+ damping: { sigma0: 1.2, sigma1: 0.006 },
36+ source: "6061-T6 sheet and plate, typical values.",
37+ },
38+ {
39+ id: "c260-brass",
40+ material: { name: "C260 cartridge brass", E: 110e9, nu: 0.375, rho: 8530 },
41+ damping: { sigma0: 1.0, sigma1: 0.006 },
42+ source: "70/30 brass, half-hard sheet, typical values.",
43+ },
44+ {
45+ id: "ti-6al-4v",
46+ material: { name: "Ti-6Al-4V titanium", E: 113.8e9, nu: 0.342, rho: 4430 },
47+ damping: { sigma0: 0.8, sigma1: 0.004 },
48+ source: "Annealed Grade 5 titanium sheet, typical values.",
49+ },
50+ {
51+ id: "b20-bronze",
52+ material: { name: "B20 bell bronze (CuSn20)", E: 105e9, nu: 0.34, rho: 8800 },
53+ damping: { sigma0: 0.4, sigma1: 0.002 },
54+ source:
55+ "Bell and cymbal bronze with about 20% tin; E ranges 100 to 110 GPa depending on the casting and working. Low damping is what makes it ring.",
56+ },
57+ {
58+ id: "b8-bronze",
59+ material: { name: "B8 bronze (CuSn8)", E: 110e9, nu: 0.34, rho: 8800 },
60+ damping: { sigma0: 0.5, sigma1: 0.0025 },
61+ source: "8% tin bronze used for cymbals and as phosphor bronze sheet; typical values.",
62+ },
63+ {
64+ id: "1095-spring-steel",
65+ material: { name: "1095 spring steel (bar stock)", E: 205e9, nu: 0.29, rho: 7850 },
66+ damping: { sigma0: 0.5, sigma1: 0.003 },
67+ source: "High-carbon steel used for glockenspiel and chime bars; E and rho typical of carbon steel.",
68+ },
69+];
70+
71+export interface PlatePreset {
72+ id: string;
73+ name: string;
74+ plate: PlateGeometry;
75+ /** Notes on what this stands in for */
76+ note: string;
77+}
78+
79+const inch = 0.0254;
80+
81+export const PLATE_PRESETS: PlatePreset[] = [
82+ {
83+ id: "sheet-300x200x2",
84+ name: "300 x 200 x 2 mm sheet",
85+ plate: { Lx: 0.3, Ly: 0.2, h: 0.002 },
86+ note: "A common cut-to-size sheet; the default problem.",
87+ },
88+ {
89+ id: "sheet-12x12x1-8in",
90+ name: '12 x 12 x 1/8 in plate',
91+ plate: { Lx: 12 * inch, Ly: 12 * inch, h: inch / 8 },
92+ note: "Square stock in imperial sizes.",
93+ },
94+ {
95+ id: "sheet-500x300x1",
96+ name: "500 x 300 x 1 mm sheet",
97+ plate: { Lx: 0.5, Ly: 0.3, h: 0.001 },
98+ note: "Thin and large: many modes below 20 kHz, expensive for finite differences.",
99+ },
100+ {
101+ id: "cymbal-flat-400x400x1p2",
102+ name: "Flat cymbal stand-in, 400 x 400 x 1.2 mm",
103+ plate: { Lx: 0.4, Ly: 0.4, h: 0.0012 },
104+ note: "A real cymbal is circular, curved and free at the edge; this rectangular, supported plate is only a stand-in for its thickness and span.",
105+ },
106+ {
107+ id: "glockenspiel-bar",
108+ name: "Glockenspiel bar, 200 x 32 x 6 mm",
109+ plate: { Lx: 0.2, Ly: 0.032, h: 0.006 },
110+ note: "Real bars are free at both ends and rest on nodal supports; supported or clamped edges give different frequencies. Thickness is a fifth of the width, at the edge of thin-plate validity.",
111+ },
112+];
packages/core/src/spec/types.tsadded+138−0View file
@@ -0,0 +1,138 @@
1+/**
2+ * The problem specification. This mirrors, field for field, the `params` struct
3+ * that the generated MATLAB source builds (see generate/params.ts), so that the
4+ * controls, the source on screen and the numbers numbl computes all describe
5+ * the same object.
6+ *
7+ * Units are SI throughout: metres, seconds, newtons, pascals, kilograms.
8+ */
9+
10+export type BoundaryCondition = "simply-supported" | "clamped";
11+
12+export interface PlateGeometry {
13+ /** Side along x [m] */
14+ Lx: number;
15+ /** Side along y [m] */
16+ Ly: number;
17+ /** Thickness [m] */
18+ h: number;
19+}
20+
21+export interface Material {
22+ name: string;
23+ /** Young's modulus [Pa] */
24+ E: number;
25+ /** Poisson's ratio */
26+ nu: number;
27+ /** Density [kg/m^3] */
28+ rho: number;
29+}
30+
31+/**
32+ * Two-parameter loss model (Bilbao 2009): a frequency-independent rate sigma0
33+ * [1/s] and a term sigma1 [m^2/s] that grows with spatial frequency. A mode
34+ * with Laplacian eigenvalue lambda decays at rate sigma0 + sigma1 * lambda.
35+ */
36+export interface Damping {
37+ sigma0: number;
38+ sigma1: number;
39+}
40+
41+export interface ImpulseExcitation {
42+ type: "impulse";
43+ x: number;
44+ y: number;
45+ /** Peak force of the half-sine pulse [N] */
46+ force: number;
47+ /** Contact time [s] */
48+ duration: number;
49+ /** Radius of the raised-cosine footprint [m] */
50+ radius: number;
51+}
52+
53+export interface HarmonicExcitation {
54+ type: "harmonic";
55+ x: number;
56+ y: number;
57+ /** Force amplitude [N] */
58+ force: number;
59+ /** Drive frequency [Hz] */
60+ frequency: number;
61+ /** Radius of the raised-cosine footprint [m] */
62+ radius: number;
63+}
64+
65+export type Excitation = ImpulseExcitation | HarmonicExcitation;
66+export type ExcitationType = Excitation["type"];
67+
68+export interface Probe {
69+ x: number;
70+ y: number;
71+}
72+
73+export interface Listener {
74+ /** Position relative to the plate corner [m]; z is the height above the plate */
75+ x: number;
76+ y: number;
77+ z: number;
78+ /** Speed of sound [m/s] */
79+ c: number;
80+ /** Air density [kg/m^3] */
81+ rho_air: number;
82+ /** Patch grid [nx, ny] used by the modal method's Rayleigh integral */
83+ patches: [number, number];
84+}
85+
86+export interface OutputSettings {
87+ /** Sample rate of the probe series and the audio [Hz] */
88+ fs: number;
89+ /** Simulated duration for the modal method and the audio [s] */
90+ duration: number;
91+ /** Number of animation frames to keep */
92+ nframes: number;
93+ /** Display grid [nx, ny] on which the modal method evaluates its fields */
94+ grid: [number, number];
95+}
96+
97+export interface ModalSettings {
98+ /** Keep modes with natural frequency below this [Hz] */
99+ fmax: number;
100+ /** Hard cap on the number of modes */
101+ max_modes: number;
102+}
103+
104+export interface FdSettings {
105+ /** Grid nodes along x and y, edges included */
106+ nx: number;
107+ ny: number;
108+ /** Fraction of the largest stable time step; > 1 is unstable */
109+ cfl: number;
110+ /** Simulated duration [s] */
111+ duration: number;
112+}
113+
114+export interface ImplicitSettings {
115+ /** Grid nodes along x and y, edges included */
116+ nx: number;
117+ ny: number;
118+ /** Simulated duration [s] */
119+ duration: number;
120+ /** Time steps per output sample: dt = 1 / (fs * steps_per_sample) */
121+ steps_per_sample: number;
122+ /** Time-averaging weight of the stiffness term; >= 1/4 is unconditionally stable, 1/4 is the trapezoidal rule */
123+ theta: number;
124+}
125+
126+export interface ProblemSpec {
127+ plate: PlateGeometry;
128+ material: Material;
129+ bc: BoundaryCondition;
130+ damping: Damping;
131+ excitation: Excitation;
132+ probe: Probe;
133+ listener: Listener;
134+ output: OutputSettings;
135+ modal: ModalSettings;
136+ fd: FdSettings;
137+ implicit: ImplicitSettings;
138+}
packages/core/src/spec/validate.tsadded+56−0View file
@@ -0,0 +1,56 @@
1+import type { ProblemSpec } from "./types";
2+
3+export interface ValidationIssue {
4+ /** Dotted path into the spec, e.g. "plate.h" */
5+ path: string;
6+ message: string;
7+ severity: "error" | "warning";
8+}
9+
10+/**
11+ * Structural checks a control panel can show before running anything. The
12+ * `.m` code repeats the checks that matter for the numerics, so a spec that
13+ * passes here can still be rejected by the solver with a legible message.
14+ */
15+export function validateSpec(spec: ProblemSpec): ValidationIssue[] {
16+ const out: ValidationIssue[] = [];
17+ const err = (path: string, message: string) => out.push({ path, message, severity: "error" });
18+ const warn = (path: string, message: string) => out.push({ path, message, severity: "warning" });
19+
20+ const { plate, material, excitation, probe, output, fd, modal, damping, implicit } = spec;
21+ if (!(plate.Lx > 0)) err("plate.Lx", "must be positive");
22+ if (!(plate.Ly > 0)) err("plate.Ly", "must be positive");
23+ if (!(plate.h > 0)) err("plate.h", "must be positive");
24+ if (plate.h > 0.1 * Math.min(plate.Lx, plate.Ly))
25+ warn("plate.h", "thickness above a tenth of the shorter side: thin-plate theory is unreliable");
26+ if (!(material.E > 0)) err("material.E", "must be positive");
27+ if (!(material.nu >= 0 && material.nu < 0.5)) err("material.nu", "must lie in [0, 0.5)");
28+ if (!(material.rho > 0)) err("material.rho", "must be positive");
29+ if (damping.sigma0 < 0 || damping.sigma1 < 0) err("damping", "loss rates cannot be negative");
30+
31+ const inside = (x: number, y: number) => x >= 0 && x <= plate.Lx && y >= 0 && y <= plate.Ly;
32+ if (!inside(excitation.x, excitation.y)) err("excitation", "strike point lies outside the plate");
33+ if (!inside(probe.x, probe.y)) err("probe", "probe lies outside the plate");
34+ if (!(excitation.radius > 0)) err("excitation.radius", "footprint radius must be positive");
35+ if (excitation.type === "impulse" && !(excitation.duration > 0))
36+ err("excitation.duration", "contact time must be positive");
37+ if (excitation.type === "harmonic" && !(excitation.frequency > 0))
38+ err("excitation.frequency", "drive frequency must be positive");
39+
40+ if (!(output.fs > 0)) err("output.fs", "sample rate must be positive");
41+ if (!(output.duration > 0)) err("output.duration", "duration must be positive");
42+ if (output.nframes < 2) err("output.nframes", "need at least 2 frames");
43+ if (modal.fmax > output.fs / 2)
44+ warn("modal.fmax", "modes above half the sample rate alias in the probe series and audio");
45+ if (fd.nx < 5 || fd.ny < 5) err("fd", "finite-difference grid needs at least 5 nodes per side");
46+ if (fd.cfl > 1) warn("fd.cfl", "above the stability limit: the finite-difference run will blow up");
47+ if (implicit.nx < 5 || implicit.ny < 5) err("implicit", "implicit grid needs at least 5 nodes per side");
48+ if (!(implicit.steps_per_sample > 0)) err("implicit.steps_per_sample", "must be positive");
49+ if (implicit.theta < 0.25) warn("implicit.theta", "below 1/4 the implicit scheme is only conditionally stable");
50+ if (implicit.steps_per_sample < 1) warn("implicit.steps_per_sample", "fewer than one step per sample detunes modes near half the sample rate by tens of percent");
51+ const dxFd = plate.Lx / (fd.nx - 1);
52+ const dyFd = plate.Ly / (fd.ny - 1);
53+ if (excitation.radius < Math.max(dxFd, dyFd))
54+ warn("excitation.radius", "footprint smaller than a finite-difference cell: the load lands on one node");
55+ return out;
56+}
packages/core/test/compare.test.tsadded+31−0View file
@@ -0,0 +1,31 @@
1+import { describe, expect, it } from "vitest";
2+import { buildCompareBundle, parseCompareResult } from "../src/index";
3+import { relL2, runner, solve, testSpec } from "./helpers";
4+
5+describe("modal versus finite differences on the same problem", () => {
6+ it("agree on displacement to about a percent and on stress to a few percent", async () => {
7+ const spec = testSpec();
8+ // Match the modal run to the FD window and grid so frames line up
9+ spec.output.duration = spec.fd.duration;
10+ spec.output.grid = [spec.fd.nx, spec.fd.ny];
11+ const modal = await solve(spec, "modal");
12+ const fd = await solve(spec, "fd");
13+ const cmpRun = await runner.run({
14+ bundle: buildCompareBundle(),
15+ inputs: { A: modal.run.ok ? modal.run.value : 0, B: fd.run.ok ? fd.run.value : 0 },
16+ });
17+ expect(cmpRun.ok).toBe(true);
18+ if (!cmpRun.ok) return;
19+ const cmp = parseCompareResult(cmpRun.value);
20+ expect(cmp.w_rel_l2).toBeLessThan(0.03);
21+ expect(cmp.vm_rel_l2).toBeLessThan(0.1);
22+ // stress error exceeds displacement error, as a second derivative should
23+ expect(cmp.vm_rel_l2).toBeGreaterThan(cmp.w_rel_l2);
24+ expect(Math.abs(cmp.w_peak_a - cmp.w_peak_b) / cmp.w_peak_a).toBeLessThan(0.03);
25+ // frames compared on the shared grid
26+ expect(cmp.frames_rel_l2.length).toBe(modal.result.frames.t.length);
27+ expect(cmp.frames_diff).not.toBeNull();
28+ // the JS-side norm of the aligned series agrees with the .m norm
29+ expect(relL2(cmp.w_a, cmp.w_b)).toBeCloseTo(cmp.w_rel_l2, 6);
30+ });
31+});
packages/core/test/fd.test.tsadded+62−0View file
@@ -0,0 +1,62 @@
1+import { describe, expect, it } from "vitest";
2+import { buildPlanBundle, getMethod, parseFdPlan } from "../src/index";
3+import { runner, solve, testSpec, maxAbs } from "./helpers";
4+
5+describe("finite differences", () => {
6+ it("plans a time step at the stability bound", async () => {
7+ const spec = testSpec();
8+ const run = await runner.run({ bundle: buildPlanBundle(spec, getMethod("fd"))! });
9+ expect(run.ok).toBe(true);
10+ if (!run.ok) return;
11+ const plan = parseFdPlan(run.value);
12+ const { Lx, Ly, h } = spec.plate;
13+ const { E, nu, rho } = spec.material;
14+ const kappa = Math.sqrt((E * h ** 3) / (12 * (1 - nu ** 2)) / (rho * h));
15+ const dx = Lx / (spec.fd.nx - 1);
16+ const dy = Ly / (spec.fd.ny - 1);
17+ const lamMax = 4 / dx ** 2 + 4 / dy ** 2;
18+ const s1 = spec.damping.sigma1;
19+ const dtMax = 2 / (lamMax * (s1 + Math.sqrt(kappa ** 2 + s1 ** 2)));
20+ expect(plan.dt_max).toBeCloseTo(dtMax, 12);
21+ expect(plan.dt).toBeCloseTo(spec.fd.cfl * dtMax, 12);
22+ expect(plan.cell_steps).toBe(plan.ncells * plan.nsteps);
23+ });
24+
25+ it("is stable just below the CFL limit and diverges just above it", async () => {
26+ const spec = testSpec();
27+ spec.fd.cfl = 0.98;
28+ const stable = await solve(spec, "fd");
29+ expect(stable.result.info.stable).toBe(true);
30+ expect(stable.result.probe.w.every(Number.isFinite)).toBe(true);
31+
32+ spec.fd.cfl = 1.03;
33+ const blown = await solve(spec, "fd");
34+ expect(blown.result.info.stable).toBe(false);
35+ expect(blown.result.info.diverged_step).toBeGreaterThan(0);
36+ expect(blown.result.info.diverged_step).toBeLessThanOrEqual(blown.result.info.nsteps);
37+ // everything returned is still finite, so the interface can show the blow-up
38+ expect(blown.result.probe.w.every(Number.isFinite)).toBe(true);
39+ expect(blown.result.frames.w.data.every(Number.isFinite)).toBe(true);
40+ expect(blown.result.info.notes.some((n) => /diverged/.test(n))).toBe(true);
41+ });
42+
43+ it("supports clamped edges and gives a stiffer response than simply supported", async () => {
44+ const ss = testSpec();
45+ const cl = testSpec();
46+ cl.bc = "clamped";
47+ const a = await solve(ss, "fd");
48+ const b = await solve(cl, "fd");
49+ expect(b.result.info.stable).toBe(true);
50+ expect(maxAbs(b.result.probe.w)).toBeLessThan(maxAbs(a.result.probe.w));
51+ });
52+
53+ it("reports its problem size and a short audio clip", async () => {
54+ const spec = testSpec();
55+ const { result } = await solve(spec, "fd");
56+ expect(result.info.ncells).toBe(spec.fd.nx * spec.fd.ny);
57+ expect(result.info.extra.cell_steps).toBe(result.info.ncells * result.info.nsteps);
58+ const expectedAudio = Math.ceil(result.t.length * result.info.dt * spec.output.fs);
59+ expect(Math.abs(result.audio.p.length - expectedAudio)).toBeLessThanOrEqual(1);
60+ expect(result.audio.p.every(Number.isFinite)).toBe(true);
61+ });
62+});
packages/core/test/generate.test.tsadded+74−0View file
@@ -0,0 +1,74 @@
1+import { describe, expect, it } from "vitest";
2+import {
3+ buildRunBundle,
4+ formatNumber,
5+ generateParamsBlock,
6+ getMethod,
7+ isPlainStruct,
8+ withEdits,
9+ LIBRARY_FILES,
10+ validateSpec,
11+} from "../src/index";
12+import { runner, testSpec } from "./helpers";
13+
14+describe("source generation and editing", () => {
15+ it("formats numbers as exact, readable MATLAB literals", () => {
16+ expect(formatNumber(200e9)).toBe("200e9");
17+ expect(formatNumber(0.002)).toBe("0.002");
18+ expect(formatNumber(44100)).toBe("44100");
19+ expect(formatNumber(1e-3)).toBe("0.001");
20+ expect(formatNumber(2.5e-7)).toBe("250e-9");
21+ expect(formatNumber(7850)).toBe("7850");
22+ expect(Number(formatNumber(68.9e9))).toBe(68.9e9);
23+ expect(formatNumber(-3)).toBe("-3");
24+ });
25+
26+ it("round-trips the spec through the params block", async () => {
27+ const spec = testSpec();
28+ const block = generateParamsBlock(spec);
29+ const bundle = {
30+ files: [{ name: "p.m", source: block }, ...LIBRARY_FILES],
31+ entry: "p.m",
32+ resultVariable: "params",
33+ };
34+ const run = await runner.run({ bundle });
35+ expect(run.ok).toBe(true);
36+ if (!run.ok || !isPlainStruct(run.value)) return;
37+ const plate = run.value.fields.plate;
38+ const mat = run.value.fields.material;
39+ if (!isPlainStruct(plate) || !isPlainStruct(mat)) throw new Error("params fields are not structs");
40+ expect(plate.fields.h).toBe(spec.plate.h);
41+ expect(mat.fields.E).toBe(spec.material.E);
42+ expect(mat.fields.name).toBe(spec.material.name);
43+ });
44+
45+ it("runs the edited source, and surfaces numbl errors with file and line", async () => {
46+ const spec = testSpec();
47+ const bundle = buildRunBundle(spec, getMethod("fd"));
48+ const main = bundle.files.find((f) => f.name === "main.m")!.source;
49+ const edited = withEdits(bundle, { "main.m": main.replace("result = plate_fd(params);", "result = plate_fd(params)\nresult = undefined_function_xyz(1);") });
50+ const run = await runner.run({ bundle: edited });
51+ expect(run.ok).toBe(false);
52+ if (run.ok) return;
53+ expect(run.error).toMatch(/undefined_function_xyz/);
54+ expect(run.error).toMatch(/main\.m/);
55+ });
56+
57+ it("reports a missing result variable rather than an empty screen", async () => {
58+ const spec = testSpec();
59+ const bundle = buildRunBundle(spec, getMethod("fd"));
60+ const edited = withEdits(bundle, { "main.m": "x = 1;" });
61+ const run = await runner.run({ bundle: edited });
62+ expect(run.ok).toBe(false);
63+ if (!run.ok) expect(run.error).toMatch(/result/);
64+ });
65+
66+ it("validates obviously bad specs", () => {
67+ const spec = testSpec();
68+ spec.probe.x = 10;
69+ spec.fd.cfl = 1.2;
70+ const issues = validateSpec(spec);
71+ expect(issues.some((i) => i.path === "probe" && i.severity === "error")).toBe(true);
72+ expect(issues.some((i) => i.path === "fd.cfl" && i.severity === "warning")).toBe(true);
73+ });
74+});
packages/core/test/helpers.tsadded+51−0View file
@@ -0,0 +1,51 @@
1+import { NodeRunner } from "../src/node";
2+import {
3+ buildRunBundle,
4+ cloneSpec,
5+ defaultSpec,
6+ getMethod,
7+ parsePlateResult,
8+ type PlateResult,
9+ type ProblemSpec,
10+ type RunResult,
11+} from "../src/index";
12+
13+export const runner = new NodeRunner();
14+
15+/** A small, fast problem for tests: coarse grid, short windows. */
16+export function testSpec(): ProblemSpec {
17+ const s = defaultSpec();
18+ s.output.duration = 0.05;
19+ s.output.nframes = 12;
20+ s.output.grid = [31, 21];
21+ s.fd.nx = 31;
22+ s.fd.ny = 21;
23+ s.fd.duration = 0.003;
24+ s.listener.patches = [12, 8];
25+ s.implicit = { nx: 31, ny: 21, duration: 0.003, steps_per_sample: 1, theta: 0.25 };
26+ return s;
27+}
28+
29+export async function solve(spec: ProblemSpec, methodId: string): Promise<{ run: RunResult; result: PlateResult }> {
30+ const run = await runner.run({ bundle: buildRunBundle(spec, getMethod(methodId)) });
31+ if (!run.ok) throw new Error(`${methodId} failed: ${run.error}\n${run.output.join("")}`);
32+ return { run, result: parsePlateResult(run.value) };
33+}
34+
35+export function relL2(a: ArrayLike<number>, b: ArrayLike<number>): number {
36+ let num = 0;
37+ let den = 0;
38+ for (let i = 0; i < a.length; i++) {
39+ num += (a[i] - b[i]) ** 2;
40+ den += a[i] ** 2;
41+ }
42+ return Math.sqrt(num / Math.max(den, 1e-300));
43+}
44+
45+export function maxAbs(a: ArrayLike<number>): number {
46+ let m = 0;
47+ for (let i = 0; i < a.length; i++) m = Math.max(m, Math.abs(a[i]));
48+ return m;
49+}
50+
51+export { cloneSpec };
packages/core/test/implicit.test.tsadded+79−0View file
@@ -0,0 +1,79 @@
1+import { describe, expect, it } from "vitest";
2+import { LIBRARY_FILES, buildCompareBundle, isPlainStruct, parseCompareResult } from "../src/index";
3+import { maxAbs, runner, solve, testSpec } from "./helpers";
4+
5+describe("implicit finite differences", () => {
6+ it("assembles the same operator as the explicit ghost-node stencils, for both edge conditions", async () => {
7+ const check = `
8+ for bc = {'simply-supported', 'clamped'}
9+ nx = 19; ny = 13; dx = 0.3/(nx-1); dy = 0.2/(ny-1);
10+ [L, B] = plate_matrices(nx, ny, dx, dy, bc{1});
11+ w = zeros(ny, nx); w(2:end-1, 2:end-1) = reshape(sin(1:(ny-2)*(nx-2)), ny-2, nx-2);
12+ Lw = plate_laplacian(plate_pad(w, bc{1}), dx, dy);
13+ Bw = zeros(ny, nx);
14+ Bw(2:end-1,2:end-1) = (Lw(3:end,2:end-1) - 2*Lw(2:end-1,2:end-1) + Lw(1:end-2,2:end-1))/dy^2 ...
15+ + (Lw(2:end-1,3:end) - 2*Lw(2:end-1,2:end-1) + Lw(2:end-1,1:end-2))/dx^2;
16+ wi = w(2:end-1,2:end-1); Bi = Bw(2:end-1,2:end-1);
17+ err.(strrep(bc{1}, '-', '_')) = norm(B*wi(:) - Bi(:)) / norm(Bi(:));
18+ sp.(strrep(bc{1}, '-', '_')) = issparse(B) && issparse(L);
19+ end
20+ out = struct('err', err, 'sp', sp);
21+ `;
22+ const run = await runner.run({ bundle: { files: [{ name: "c.m", source: check }, ...LIBRARY_FILES], entry: "c.m", resultVariable: "out" } });
23+ expect(run.ok).toBe(true);
24+ if (!run.ok || !isPlainStruct(run.value)) return;
25+ const err = run.value.fields.err;
26+ const sp = run.value.fields.sp;
27+ if (!isPlainStruct(err) || !isPlainStruct(sp)) throw new Error("unexpected shape");
28+ expect(err.fields.simply_supported as number).toBeLessThan(1e-12);
29+ expect(err.fields.clamped as number).toBeLessThan(1e-12);
30+ expect(sp.fields.simply_supported).toBe(true);
31+ expect(sp.fields.clamped).toBe(true);
32+ });
33+
34+ it("agrees with the explicit scheme on the same grid and with the modal solution", async () => {
35+ const spec = testSpec();
36+ spec.implicit = { nx: spec.fd.nx, ny: spec.fd.ny, duration: spec.fd.duration, steps_per_sample: 2, theta: 0.25 };
37+ spec.output.duration = spec.fd.duration;
38+ spec.output.grid = [spec.fd.nx, spec.fd.ny];
39+ const imp = await solve(spec, "implicit");
40+ const exp = await solve(spec, "fd");
41+ const modal = await solve(spec, "modal");
42+ expect(imp.result.method).toBe("implicit");
43+ expect(imp.result.info.stable).toBe(true);
44+ expect(imp.result.info.nsteps).toBeLessThan(exp.result.info.nsteps);
45+ const cmpE = await runner.run({ bundle: buildCompareBundle(), inputs: { A: exp.run.ok ? exp.run.value : 0, B: imp.run.ok ? imp.run.value : 0 } });
46+ const cmpM = await runner.run({ bundle: buildCompareBundle(), inputs: { A: modal.run.ok ? modal.run.value : 0, B: imp.run.ok ? imp.run.value : 0 } });
47+ expect(cmpE.ok && cmpM.ok).toBe(true);
48+ if (!cmpE.ok || !cmpM.ok) return;
49+ const e = parseCompareResult(cmpE.value);
50+ const m = parseCompareResult(cmpM.value);
51+ // same spatial operator, different time discretization: the two FD schemes agree closely
52+ expect(e.w_rel_l2).toBeLessThan(0.02);
53+ expect(m.w_rel_l2).toBeLessThan(0.05);
54+ expect(m.vm_rel_l2).toBeLessThan(0.15);
55+ expect(m.frames_rel_l2.length).toBeGreaterThan(0);
56+ });
57+
58+ it("is unconditionally stable for theta = 1/4 and blows up for theta = 0 at the same step", async () => {
59+ const spec = testSpec();
60+ spec.implicit = { nx: 31, ny: 21, duration: 0.01, steps_per_sample: 0.25, theta: 0.25 };
61+ const coarse = await solve(spec, "implicit");
62+ expect(coarse.result.info.stable).toBe(true);
63+ expect(maxAbs(coarse.result.probe.w)).toBeLessThan(1e-3);
64+ spec.implicit = { nx: 31, ny: 21, duration: 0.002, steps_per_sample: 1, theta: 0 };
65+ const explicitLike = await solve(spec, "implicit");
66+ expect(explicitLike.result.info.stable).toBe(false);
67+ expect(explicitLike.result.info.diverged_step).toBeGreaterThan(0);
68+ expect(explicitLike.result.probe.w.every(Number.isFinite)).toBe(true);
69+ });
70+
71+ it("supports clamped edges", async () => {
72+ const spec = testSpec();
73+ spec.bc = "clamped";
74+ spec.implicit = { nx: 31, ny: 21, duration: 0.003, steps_per_sample: 1, theta: 0.25 };
75+ const r = await solve(spec, "implicit");
76+ expect(r.result.info.stable).toBe(true);
77+ expect(maxAbs(r.result.probe.w)).toBeGreaterThan(0);
78+ });
79+});
packages/core/test/modal.test.tsadded+64−0View file
@@ -0,0 +1,64 @@
1+import { describe, expect, it } from "vitest";
2+import { solve, testSpec, maxAbs } from "./helpers";
3+
4+describe("modal superposition", () => {
5+ it("reproduces the closed-form fundamental frequency of the simply supported rectangle", async () => {
6+ const spec = testSpec();
7+ const { result } = await solve(spec, "modal");
8+ const { Lx: a, Ly: b, h } = spec.plate;
9+ const { E, nu, rho } = spec.material;
10+ const D = (E * h ** 3) / (12 * (1 - nu ** 2));
11+ const kappa = Math.sqrt(D / (rho * h));
12+ const f11 = (kappa / (2 * Math.PI)) * ((Math.PI / a) ** 2 + (Math.PI / b) ** 2);
13+ expect(result.modes).toBeDefined();
14+ expect(result.modes!.f_hz[0]).toBeCloseTo(f11, 6);
15+ expect(result.modes!.m[0]).toBe(1);
16+ expect(result.modes!.n[0]).toBe(1);
17+ // Modes are sorted by frequency and all below fmax
18+ const f = result.modes!.f_hz;
19+ for (let k = 1; k < f.length; k++) expect(f[k]).toBeGreaterThanOrEqual(f[k - 1]);
20+ expect(f[f.length - 1]).toBeLessThanOrEqual(spec.modal.fmax);
21+ });
22+
23+ it("produces a finite, decaying response with a plausible amplitude", async () => {
24+ const spec = testSpec();
25+ const { result, run } = await solve(spec, "modal");
26+ expect(result.method).toBe("modal");
27+ expect(result.info.stable).toBe(true);
28+ expect(result.t.length).toBe(Math.round(spec.output.fs * spec.output.duration) + 1);
29+ const w = result.probe.w;
30+ expect(w.every(Number.isFinite)).toBe(true);
31+ // 50 N hammer on a 2 mm steel sheet: deflection of order 0.1 mm, stress of order 10 MPa
32+ const peak = maxAbs(w);
33+ expect(peak).toBeGreaterThan(1e-5);
34+ expect(peak).toBeLessThan(1e-3);
35+ const vmPeak = maxAbs(result.probe.vm);
36+ expect(vmPeak).toBeGreaterThan(1e6);
37+ expect(vmPeak).toBeLessThan(1e8);
38+ // damping: the last tenth of the record is quieter than the first tenth
39+ const n = w.length;
40+ expect(maxAbs(w.subarray(Math.floor(0.9 * n)))).toBeLessThan(maxAbs(w.subarray(0, Math.floor(0.1 * n))));
41+ // frames and audio have the promised shapes
42+ expect(result.frames.w.nx).toBe(spec.output.grid[0]);
43+ expect(result.frames.w.ny).toBe(spec.output.grid[1]);
44+ expect(result.frames.w.nf).toBe(result.frames.t.length);
45+ expect(result.audio.p.length).toBe(result.t.length);
46+ expect(result.audio.fs).toBe(spec.output.fs);
47+ expect(run.backend.engine).toBe("numbl");
48+ expect(run.wallMs).toBeGreaterThan(0);
49+ });
50+
51+ it("refuses clamped edges with a legible message", async () => {
52+ const spec = testSpec();
53+ spec.bc = "clamped";
54+ await expect(solve(spec, "modal")).rejects.toThrow(/simply supported/);
55+ });
56+
57+ it("handles harmonic forcing", async () => {
58+ const spec = testSpec();
59+ spec.excitation = { type: "harmonic", x: 0.1, y: 0.08, force: 5, frequency: 500, radius: 0.012 };
60+ const { result } = await solve(spec, "modal");
61+ expect(result.probe.w.every(Number.isFinite)).toBe(true);
62+ expect(maxAbs(result.probe.w)).toBeGreaterThan(0);
63+ });
64+});
packages/core/tsconfig.jsonadded+10−0View file
@@ -0,0 +1,10 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ "noEmit": true
5+ },
6+ "include": [
7+ "src",
8+ "test"
9+ ]
10+}
tsconfig.base.jsonadded+17−0View file
@@ -0,0 +1,17 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
5+ "module": "ESNext",
6+ "moduleResolution": "bundler",
7+ "strict": true,
8+ "noUnusedLocals": true,
9+ "noUnusedParameters": true,
10+ "noFallthroughCasesInSwitch": true,
11+ "skipLibCheck": true,
12+ "isolatedModules": true,
13+ "resolveJsonModule": true,
14+ "jsx": "react-jsx",
15+ "types": ["vite/client"]
16+ }
17+}
tsconfig.jsonadded+5−0View file
@@ -0,0 +1,5 @@
1+{
2+ "extends": "./tsconfig.base.json",
3+ "compilerOptions": { "noEmit": true },
4+ "include": ["packages/*/src", "packages/*/test", "apps/*/src", "apps/*/vite.config.ts", "vite.shared.ts", "vitest.config.ts"]
5+}
vite.shared.tsadded+19−0View file
@@ -0,0 +1,19 @@
1+import { readFileSync } from "node:fs";
2+import { fileURLToPath } from "node:url";
3+import path from "node:path";
4+
5+/**
6+ * Vite `define` entries every host needs to run numbl in the browser:
7+ * numbl reads `process` in a few guarded places, and the lab reports numbl's
8+ * package version (its `exports` map does not expose package.json to imports).
9+ */
10+export function numblDefine(): Record<string, string> {
11+ const entry = fileURLToPath(import.meta.resolve("numbl"));
12+ const pkgPath = path.join(path.dirname(entry), "..", "package.json");
13+ const version = (JSON.parse(readFileSync(pkgPath, "utf8")) as { version: string }).version;
14+ return {
15+ "process.platform": JSON.stringify("browser"),
16+ "process.env": "{}",
17+ __NUMBL_VERSION__: JSON.stringify(version),
18+ };
19+}
vitest.config.tsadded+10−0View file
@@ -0,0 +1,10 @@
1+import { defineConfig } from "vitest/config";
2+import { numblDefine } from "./vite.shared";
3+
4+export default defineConfig({
5+ define: { __NUMBL_VERSION__: numblDefine().__NUMBL_VERSION__ },
6+ test: {
7+ include: ["packages/*/test/**/*.test.ts"],
8+ testTimeout: 120_000,
9+ },
10+});