/ proof-of-concept / plate-dynamics-lab
Sign in
proof-of-concept / plate-dynamics-lab
plate-dynamics-lab / apps / lab / src / views / ResultsView.tsx
565 lines · 17.8 KBCodeBlameHistory
80c0b6dPlate dynamics lab: plates in realizable mounts under gravity, explicit finite differences on WebGPUJeremy Magland 1import { useEffect, useMemo, useState } from "react";
2import {
3 rangeOf,
4 symmetricRange,
5 type Field2D,
6 type FieldFrames,
7 type MethodDefinition,
8 type PlateResult,
9 type ProblemSpec,
10 type Range,
11} from "@plate-dyn/core";
12import {
13 AudioPlayer,
14 FieldView,
15 FramePlayer,
16 LinePlot,
17 Plate3DView,
18 ValidityNotes,
19 useFramePlayer,
20 useSharedAxis,
21 type FieldArrow,
22 type FieldMarker,
23 type FieldRect,
24} from "@plate-dyn/components";
26type Quantity = "w" | "w_total" | "vm" | "sp" | "env_vm" | "env_sp" | "w0" | "nvm";
28const QUANTITIES: {
29 id: Quantity;
30 label: string;
31 unit: string;
32 scale: number;
33 symmetric: boolean;
34 frames: boolean;
35}[] = [
36 {
37 id: "w",
38 label: "Flexural displacement",
39 unit: "µm",
40 scale: 1e6,
41 symmetric: true,
42 frames: true,
43 },
44 {
45 id: "w_total",
46 label: "Displacement with the rigid-body motion",
47 unit: "mm",
48 scale: 1e3,
49 symmetric: true,
50 frames: true,
51 },
52 {
53 id: "vm",
54 label: "Von Mises stress (surface)",
55 unit: "MPa",
56 scale: 1e-6,
57 symmetric: false,
58 frames: true,
59 },
60 {
61 id: "sp",
62 label: "Principal stress, signed (surface)",
63 unit: "MPa",
64 scale: 1e-6,
65 symmetric: true,
66 frames: true,
67 },
68 {
69 id: "env_vm",
70 label: "Peak von Mises over time",
71 unit: "MPa",
72 scale: 1e-6,
73 symmetric: false,
74 frames: false,
75 },
76 {
77 id: "env_sp",
78 label: "Peak |principal stress| over time",
79 unit: "MPa",
80 scale: 1e-6,
81 symmetric: false,
82 frames: false,
83 },
84 {
85 id: "w0",
86 label: "Static sag under gravity",
87 unit: "mm",
88 scale: 1e3,
89 symmetric: true,
90 frames: false,
91 },
92 {
93 id: "nvm",
94 label: "Self-weight in-plane stress (von Mises)",
95 unit: "kPa",
96 scale: 1e-3,
97 symmetric: false,
98 frames: false,
99 },
100];
102export interface ResultsViewProps {
103 result: PlateResult;
104 spec: ProblemSpec;
105 method: MethodDefinition;
106 fieldWidth?: number;
109/** The plate in its mount, the fields, the rigid-body and support series, the probe, the sound. */
110export function ResultsView({ result, spec, method, fieldWidth = 460 }: ResultsViewProps) {
111 const [quantity, setQuantity] = useState<Quantity>("w");
112 const [rangeMode, setRangeMode] = useState<"global" | "frame">("global");
113 const [flexScale, setFlexScale] = useState(200);
114 const [rigidScale, setRigidScale] = useState(1);
115 const q = QUANTITIES.find((x) => x.id === quantity)!;
116 const nf = result.frames.t.length;
117 const player = useFramePlayer(nf, 30, false);
118 const startFrame = useMemo(() => peakFrame(result.frames.w), [result]);
119 const setIndex = player.setIndex;
120 useEffect(() => setIndex(startFrame), [startFrame, setIndex]);
122 const nvm = useMemo(() => inPlaneVonMises(result), [result]);
123 const frames: FieldFrames | undefined =
124 quantity === "w"
125 ? result.frames.w
126 : quantity === "w_total"
127 ? result.frames.w_total
128 : quantity === "vm"
129 ? result.frames.vm
130 : quantity === "sp"
131 ? result.frames.sp
132 : undefined;
133 const field: Field2D | undefined =
134 quantity === "env_vm"
135 ? result.envelope.vm
136 : quantity === "env_sp"
137 ? result.envelope.sp
138 : quantity === "w0"
139 ? result.static.w0
140 : quantity === "nvm"
141 ? nvm
142 : undefined;
143 const tFrame = result.frames.t[Math.min(player.index, nf - 1)] ?? 0;
145 // the 3D surface is coloured by the same quantity when it is a frame stack
146 const colorRange: Range | undefined = useMemo(() => {
147 if (!frames) return undefined;
148 const r = rangeOf(frames.data);
149 return q.symmetric ? symmetricRange(r) : r;
150 }, [frames, q.symmetric]);
152 const markers: FieldMarker[] = useMemo(() => {
153 const m: FieldMarker[] = [
154 {
155 x: spec.excitation.x,
156 y: spec.excitation.y,
157 label: spec.excitation.type === "impulse" ? "strike" : "pull",
158 color: "#d1495b",
159 },
160 { x: result.probe.x, y: result.probe.y, label: "probe", color: "#1f5fa8" },
161 ];
162 const mt = spec.mount;
163 if (mt.type === "thread") m.push({ x: mt.x, y: mt.y, label: "hole", color: "#2a9d8f" });
164 if (mt.type === "threads") {
165 m.push({ x: mt.x1, y: mt.y1, label: "hole 1", color: "#2a9d8f" });
166 m.push({ x: mt.x2, y: mt.y2, label: "hole 2", color: "#2a9d8f" });
167 }
168 if (mt.type === "pinched") m.push({ x: mt.x, y: mt.y, label: "fingers", color: "#2a9d8f" });
169 if (mt.type === "cymbal")
170 m.push({ x: spec.plate.Lx / 2, y: spec.plate.Ly / 2, label: "felts", color: "#2a9d8f" });
171 return m;
172 }, [spec, result.probe.x, result.probe.y]);
173 const rects: FieldRect[] = useMemo(() => {
174 const mt = spec.mount;
175 if (mt.type === "pads")
176 return mt.pads.map((p, k) => ({
177 x0: p.x - p.rx,
178 y0: p.y - p.ry,
179 x1: p.x + p.rx,
180 y1: p.y + p.ry,
181 label: `pad ${k + 1}`,
182 }));
183 if (mt.type === "vise") {
184 const { Lx, Ly } = spec.plate;
185 const d = mt.depth;
186 const r =
187 mt.edge === "x0"
188 ? { x0: 0, y0: 0, x1: d, y1: Ly }
189 : mt.edge === "x1"
190 ? { x0: Lx - d, y0: 0, x1: Lx, y1: Ly }
191 : mt.edge === "y0"
192 ? { x0: 0, y0: 0, x1: Lx, y1: d }
193 : { x0: 0, y0: Ly - d, x1: Lx, y1: Ly };
194 return [{ ...r, label: "jaws" }];
195 }
196 return [];
197 }, [spec]);
198 const arrow: FieldArrow | undefined = useMemo(() => {
199 const s = result.static;
200 if (s.g === 0) return undefined;
201 if (s.gx === 0 && s.gy === 0) return undefined;
202 return { x: s.xc, y: s.yc, dx: s.gx, dy: s.gy, label: "down" };
203 }, [result.static]);
205 const onPlotClick = (t: number) => {
206 let best = 0;
207 for (let k = 1; k < nf; k++)
208 if (Math.abs(result.frames.t[k] - t) < Math.abs(result.frames.t[best] - t)) best = k;
209 player.setIndex(best);
210 player.setPlaying(false);
211 };
213 const axis = useSharedAxis();
214 const spectrumAxis = useSharedAxis();
215 const audioT = useMemo(
216 () => Float64Array.from({ length: result.audio.p.length }, (_, i) => i / result.audio.fs),
217 [result.audio],
218 );
219 const rigidUnits = result.rigid.names.map((_, k) => (k === 0 ? "mm" : "mrad"));
220 const rigidColors = ["#1f5fa8", "#d1495b", "#2a9d8f"];
221 const supportColors = [
222 "#1f5fa8",
223 "#d1495b",
224 "#2a9d8f",
225 "#e9a300",
226 "#7b4fa6",
227 "#555",
228 "#a0522d",
229 "#008b8b",
230 ];
231 const orientation = describeOrientation(result, spec);
233 return (
234 <div className="results">
235 <div className="results-toolbar sound-bar">
236 <AudioPlayer
237 p={result.audio.p}
238 fs={result.audio.fs}
239 label="sound"
240 filename={`plate-${spec.mount.type}`}
241 />
242 </div>
244 <div className="pl-scene">
245 <div className="pl-plot-title">The plate in its mount</div>
246 <Plate3DView
247 result={result}
248 spec={spec}
249 frameIndex={player.index}
250 colorFrames={frames}
251 colorRange={colorRange}
252 colormap={q.symmetric ? "bwr" : "viridis"}
253 flexScale={flexScale}
254 rigidScale={rigidScale}
255 width={fieldWidth}
256 height={Math.round(fieldWidth * 0.7)}
257 />
258 <div className="pl-scene-controls">
259 <label>
260 flexure ×{flexScale}
261 <input
262 type="range"
263 min={0}
264 max={4}
265 step={0.05}
266 value={Math.log10(Math.max(1, flexScale))}
267 onChange={(e) => setFlexScale(Math.round(10 ** Number(e.target.value)))}
268 />
269 </label>
270 <label>
271 rigid motion ×{rigidScale}
272 <input
273 type="range"
274 min={0}
275 max={2}
276 step={0.05}
277 value={Math.log10(Math.max(1, rigidScale))}
278 onChange={(e) => setRigidScale(Math.round(10 ** Number(e.target.value)))}
279 />
280 </label>
281 <span className="pl-muted">drag to orbit, wheel to zoom</span>
282 </div>
283 <div className="pl-small pl-muted">
284 {orientation} The static sag and the flexural field are exaggerated by the first factor,
285 the swing and sway by the second (1 is true scale); the colour is the quantity chosen
286 below.
287 </div>
288 </div>
290 {frames && <FramePlayer {...player} times={result.frames.t} timeScale={1} timeUnit="s" />}
292 <div className="results-toolbar">
293 <label className="pl-small pl-muted">show</label>
294 <select
295 className="pl-select"
296 style={{ width: "auto" }}
297 value={quantity}
298 onChange={(e) => setQuantity(e.target.value as Quantity)}
299 >
300 {QUANTITIES.filter((x) => x.id !== "nvm" || nvm).map((x) => (
301 <option key={x.id} value={x.id}>
302 {x.label}
303 </option>
304 ))}
305 </select>
306 {frames && (
307 <label className="pl-small pl-muted">
308 colour range{" "}
309 <select
310 className="pl-select"
311 style={{ width: "auto" }}
312 value={rangeMode}
313 onChange={(e) => setRangeMode(e.target.value as "global" | "frame")}
314 >
315 <option value="global">fixed over all frames</option>
316 <option value="frame">rescaled each frame</option>
317 </select>
318 </label>
319 )}
320 {!result.info.stable && (
321 <span className="pl-badge pl-badge-error">
322 diverged at step {result.info.diverged_step}: frames stop there
323 </span>
324 )}
325 </div>
326 <FieldView
327 frames={frames}
328 field={field}
329 frameIndex={player.index}
330 Lx={spec.plate.Lx}
331 Ly={spec.plate.Ly}
332 shape={spec.plate.shape}
333 title={q.label}
334 unit={q.unit}
335 scale={q.scale}
336 symmetric={q.symmetric}
337 range={rangeMode}
338 markers={markers}
339 rects={rects}
340 arrow={arrow}
341 width={fieldWidth}
342 caption={captionFor(quantity)}
343 />
344 {spec.plate.shape === "ellipse" && (
345 <div className="pl-small pl-muted">
346 The grid spans the box around the ellipse; the edge is a staircase of grid nodes, drawn
347 here clipped to the true outline. Stress on the outermost ring is copied from the nearest
348 node with a full stencil.
349 </div>
350 )}
352 {result.rigid.nr > 0 && (
353 <>
354 <LinePlot
355 title="Rigid-body motion about the pivot"
356 series={result.rigid.series.map((s, k) => ({
357 x: result.t,
358 y: s,
359 label: `${result.rigid.names[k]} [${rigidUnits[k]}]`,
360 color: rigidColors[k],
361 }))}
362 yScale={1e3}
363 xLabel="time [s]"
364 yLabel="mm, mrad"
365 yUnit=""
366 cursorX={tFrame}
367 onClick={onPlotClick}
368 height={170}
369 {...axis}
370 />
371 <div className="pl-small pl-muted">
372 The translation along the normal at the pivot (
373 {(result.rigid.pivot.x * 1000).toFixed(0)}, {(result.rigid.pivot.y * 1000).toFixed(0)})
374 mm in millimetres, the two rotations in milliradians; all measured from the static
375 equilibrium. Rigid-body modes of this mount:{" "}
376 {result.rigid.hz
377 .map(
378 (h, k) =>
379 `${Number.isFinite(h) ? h.toFixed(2) + " Hz" : "unstable"} (${modeName(result, k)})`,
380 )
381 .join(", ")}
382 .
383 </div>
384 </>
385 )}
387 {result.supports.names.length > 0 && (
388 <>
389 <LinePlot
390 title="Force the supports apply to the plate"
391 series={result.supports.series.map((s, k) => ({
392 x: result.t,
393 y: s,
394 label: result.supports.names[k],
395 color: supportColors[k % supportColors.length],
396 }))}
397 xLabel="time [s]"
398 yLabel="F [N]"
399 yUnit="N"
400 cursorX={tFrame}
401 onClick={onPlotClick}
402 height={150}
403 {...axis}
404 />
405 <div className="pl-small pl-muted">
406 Positive pushes the plate towards +z (a pad or the lower felt), negative pulls it back
407 (a thread's transverse spring, the upper felt). At rest:{" "}
408 {result.supports.names
409 .map((n, k) => `${n} ${result.supports.static[k].toFixed(2)} N`)
410 .join(", ")}
411 . A contact at zero has lost touch with the plate.
412 </div>
413 </>
414 )}
416 <LinePlot
417 title={`Displacement at the probe (${(result.probe.x * 1000).toFixed(0)}, ${(result.probe.y * 1000).toFixed(0)}) mm`}
418 series={[{ x: result.t, y: result.probe.w, label: "w (rigid + flexural)" }]}
419 yScale={1e6}
420 xLabel="time [s]"
421 yLabel="w [µm]"
422 yUnit="µm"
423 cursorX={tFrame}
424 onClick={onPlotClick}
425 height={170}
426 {...axis}
427 />
428 <LinePlot
429 title="Surface stress at the probe"
430 series={[
431 { x: result.t, y: result.probe.vm, label: "von Mises" },
432 { x: result.t, y: result.probe.sp, label: "principal (signed)", color: "#d1495b" },
433 ]}
434 yScale={1e-6}
435 xLabel="time [s]"
436 yLabel="σ [MPa]"
437 yUnit="MPa"
438 cursorX={tFrame}
439 onClick={onPlotClick}
440 height={170}
441 {...axis}
442 />
443 <LinePlot
444 title="Amplitude spectrum at the probe (Hann window)"
445 series={[
446 { x: result.spectrum.f, y: result.spectrum.w, label: "w [m]" },
447 {
448 x: result.spectrum.f,
449 y: result.spectrum.vm,
450 label: "von Mises [Pa]",
451 color: "#d1495b",
452 },
453 ]}
454 defaultXRange={[0, Math.min(result.info.fs / 2, 5000)]}
455 logY
456 xLabel="frequency [Hz]"
457 yLabel="amplitude"
458 height={170}
459 {...spectrumAxis}
460 />
461 <div className="pl-small pl-muted">
462 Frequency resolution {(1 / (result.t[result.t.length - 1] - result.t[0])).toFixed(2)} Hz
463 from a {(result.t[result.t.length - 1] - result.t[0]).toFixed(2)} s record. The pendulum
464 modes sit at the far left; zoom in to see them.
465 </div>
467 <div>
468 <div className="pl-plot-title">Radiated sound at the listener</div>
469 <LinePlot
470 series={[{ x: audioT, y: result.audio.p, label: "pressure" }]}
471 xLabel="time after first arrival [s]"
472 yLabel="p [Pa]"
473 yUnit="Pa"
474 height={120}
475 {...axis}
476 />
477 <div className="pl-small pl-muted">
478 {result.audio.model === "unbaffled"
479 ? "Unbaffled estimate: the back face's contribution, taken around the nearest edge, cancels the front at low frequency. "
480 : "Rayleigh integral in an infinite baffle. "}
481 Listener {spec.listener.z} m from the plate; propagation delay of the nearest patch
482 removed.
483 </div>
484 </div>
486 <details>
487 <summary className="pl-small">What this result idealizes</summary>
488 <ValidityNotes method={method} result={result} />
489 </details>
490 </div>
491 );
494function captionFor(q: Quantity): string {
495 switch (q) {
496 case "w":
497 return "The flexural part of the departure from rest: the rigid-body swing and sway are taken out. Positive along the plate normal.";
498 case "w_total":
499 return "Flexural field plus the rigid-body motion, measured from the static equilibrium; the swing dominates on a hung plate.";
500 case "vm":
501 case "sp":
502 return "Surface stress at z = h/2 from the curvature of the flexural field; the opposite face carries the opposite sign.";
503 case "env_vm":
504 case "env_sp":
505 return "Peak over the saved frames, so a sample of the true peak. Surface z = h/2.";
506 case "w0":
507 return "Deflection at rest under gravity: the sag of a horizontal plate into its pads or from its clamp. A hanging plate has none.";
508 case "nvm":
509 return "Von Mises equivalent of the in-plane stress the plate's own weight puts in it, from the plane-stress solve; largest near the hole or the jaws.";
510 }
513function modeName(result: PlateResult, k: number): string {
514 return result.rigid.modeNames[k] ?? `mode ${k + 1}`;
517function describeOrientation(result: PlateResult, spec: ProblemSpec): string {
518 const s = result.static;
519 const m = spec.mount;
520 if (s.g === 0) return "Gravity is off.";
521 if (m.type === "thread" || m.type === "threads")
522 return `The plate hangs vertically; it is turned ${(Math.atan2(s.gx, -s.gy) * (180 / Math.PI)).toFixed(1)}° in its plane so its centre of mass is below the ${m.type === "thread" ? "hole" : "line of the holes"}.`;
523 if (m.type === "pads" || m.type === "cymbal") return "The plate lies horizontal on its supports.";
524 if (m.type === "vise")
525 return `The plate is tilted ${m.tilt}° from hanging straight down out of the jaws.`;
526 return `The plate is held at ${m.tilt}° from hanging straight below the fingers.`;
529/** Von Mises equivalent of the self-weight stress resultants, per unit thickness. */
530function inPlaneVonMises(result: PlateResult): Field2D | undefined {
531 const s = result.static;
532 if (!s.Nxx || !s.Nyy || !s.Nxy) return undefined;
533 const n = s.Nxx.data.length;
534 const out = new Float64Array(n);
535 for (let i = 0; i < n; i++) {
536 const a = s.Nxx.data[i];
537 const b = s.Nyy.data[i];
538 const c = s.Nxy.data[i];
539 out[i] = Math.sqrt(a * a - a * b + b * b + 3 * c * c);
540 }
541 return { nx: s.Nxx.nx, ny: s.Nxx.ny, data: out };
544/**
545 * The frame of largest flexural displacement. The player starts here so a run
546 * that has just finished shows the plate in motion.
547 */
548function peakFrame(f: FieldFrames | undefined): number {
549 if (!f) return 0;
550 const n = f.nx * f.ny;
551 let best = 0;
552 let bestv = -1;
553 for (let k = 0; k < f.nf; k++) {
554 let v = 0;
555 for (let i = k * n; i < (k + 1) * n; i++) {
556 const a = Math.abs(f.data[i]);
557 if (a > v) v = a;
558 }
559 if (v > bestv) {
560 bestv = v;
561 best = k;
562 }
563 }
564 return best;
↑↓ move↵openescclose