/ proof-of-concept / plate-dynamics-lab-2
Sign in
proof-of-concept / plate-dynamics-lab-2
plate-dynamics-lab-2 / apps / lab / src / views / ResultsView.tsx
519 lines · 16.4 KBBlameHistoryRaw
1import { useEffect, useMemo, useRef, useState } from "react";
2import {
3 holePosition,
4 prepareAudio,
5 rangeOf,
6 symmetricRange,
7 type Field2D,
8 type FieldFrames,
9 type MethodDefinition,
10 type PlateResult,
11 type ProblemSpec,
12 type Range,
13} from "@plate-dyn/core";
14import {
15 AudioPlayer,
16 FieldView,
17 FramePlayer,
18 LinePlot,
19 MovieExport,
20 Plate3DView,
21 ValidityNotes,
22 useFramePlayer,
23 useSharedAxis,
24 type FieldMarker,
25 type MovieOverlay,
26 type Plate3DHandle,
27} from "@plate-dyn/components";
28import { ModeTable } from "./ModeTable";
29import { ContactPlots } from "./ContactPlots";
31type Quantity = "w" | "w_total" | "vm" | "sp" | "env_vm" | "env_sp" | "mode";
33const QUANTITIES: {
34 id: Quantity;
35 label: string;
36 unit: string;
37 scale: number;
38 symmetric: boolean;
39 frames: boolean;
40}[] = [
41 {
42 id: "w",
43 label: "Displacement (audible modes)",
44 unit: "µm",
45 scale: 1e6,
46 symmetric: true,
47 frames: true,
48 },
49 {
50 id: "w_total",
51 label: "Displacement with the rigid-body modes",
52 unit: "µm",
53 scale: 1e6,
54 symmetric: true,
55 frames: true,
56 },
57 {
58 id: "vm",
59 label: "Von Mises stress (surface)",
60 unit: "MPa",
61 scale: 1e-6,
62 symmetric: false,
63 frames: true,
64 },
65 {
66 id: "sp",
67 label: "Principal stress, signed (surface)",
68 unit: "MPa",
69 scale: 1e-6,
70 symmetric: true,
71 frames: true,
72 },
73 {
74 id: "env_vm",
75 label: "Peak von Mises over the frames",
76 unit: "MPa",
77 scale: 1e-6,
78 symmetric: false,
79 frames: false,
80 },
81 {
82 id: "env_sp",
83 label: "Peak |principal stress| over the frames",
84 unit: "MPa",
85 scale: 1e-6,
86 symmetric: false,
87 frames: false,
88 },
89 { id: "mode", label: "A mode shape", unit: "", scale: 1, symmetric: true, frames: false },
90];
92export interface ResultsViewProps {
93 result: PlateResult;
94 spec: ProblemSpec;
95 method: MethodDefinition;
96 fieldWidth?: number;
99/** The plate on its thread, the fields, the modes, the contact, the probe, the sound. */
100export function ResultsView({ result, spec, method, fieldWidth = 460 }: ResultsViewProps) {
101 const [quantity, setQuantity] = useState<Quantity>("w");
102 const [rangeMode, setRangeMode] = useState<"global" | "frame">("global");
103 const [exaggeration, setExaggeration] = useState(300);
104 const [mode, setMode] = useState(firstAudible(result));
105 const q = QUANTITIES.find((x) => x.id === quantity)!;
106 const nf = result.frames.t.length;
107 const player = useFramePlayer(nf, 30, false);
108 const startFrame = useMemo(() => peakFrame(result.frames.w), [result]);
109 const setIndex = player.setIndex;
110 useEffect(() => setIndex(startFrame), [startFrame, setIndex]);
111 useEffect(() => setMode(firstAudible(result)), [result]);
113 const sceneRef = useRef<Plate3DHandle>(null);
114 const W = spec.plate.width;
115 const H = spec.plate.height;
116 const size = Math.max(W, H);
118 // mode shapes, each scaled to unit peak, for the picture and the scene
119 const shapes: FieldFrames = useMemo(() => normalizeShapes(result.modes.shapes), [result]);
120 const isMode = quantity === "mode";
121 const frameIndex = isMode ? mode : player.index;
122 const frames: FieldFrames | undefined =
123 quantity === "w"
124 ? result.frames.w
125 : quantity === "w_total"
126 ? result.frames.w_total
127 : quantity === "vm"
128 ? result.frames.vm
129 : quantity === "sp"
130 ? result.frames.sp
131 : isMode
132 ? shapes
133 : undefined;
134 const field: Field2D | undefined =
135 quantity === "env_vm"
136 ? result.envelope.vm
137 : quantity === "env_sp"
138 ? result.envelope.sp
139 : undefined;
140 const tFrame = result.frames.t[Math.min(player.index, nf - 1)] ?? 0;
142 // the scene is displaced by the displacement field (or the shape) and coloured by the quantity
143 const deformFrames = isMode
144 ? shapes
145 : quantity === "w_total"
146 ? result.frames.w_total
147 : result.frames.w;
148 const sceneScale = isMode ? 0.08 * size : exaggeration;
149 const colorRange: Range | undefined = useMemo(() => {
150 if (!frames) return undefined;
151 if (isMode) return { min: -1, max: 1 };
152 const r = rangeOf(frames.data);
153 return q.symmetric ? symmetricRange(r) : r;
154 }, [frames, q.symmetric, isMode]);
156 const hole = holePosition(spec);
157 const markers: FieldMarker[] = useMemo(
158 () => [
159 { x: spec.strike.x + W / 2, y: spec.strike.y + H / 2, label: "strike", color: "#d1495b" },
160 { x: result.probe.x + W / 2, y: result.probe.y + H / 2, label: "probe", color: "#1f5fa8" },
161 { x: hole.x + W / 2, y: hole.y + H / 2, label: "hole", color: "#2a9d8f" },
162 ],
163 [spec, result.probe.x, result.probe.y, hole.x, hole.y, W, H],
164 );
166 const onPlotClick = (t: number) => {
167 let best = 0;
168 for (let k = 1; k < nf; k++)
169 if (Math.abs(result.frames.t[k] - t) < Math.abs(result.frames.t[best] - t)) best = k;
170 player.setIndex(best);
171 player.setPlaying(false);
172 };
174 const axis = useSharedAxis();
175 const spectrumAxis = useSharedAxis();
176 const audioT = useMemo(
177 () => Float64Array.from({ length: result.audio.p.length }, (_, i) => i / result.audio.fs),
178 [result.audio],
179 );
180 const audioPrep = useMemo(
181 () => prepareAudio(result.audio.p, result.audio.fs, 10 ** (result.audio.normalize_dbfs / 20)),
182 [result.audio],
183 );
184 const movieSource = useMemo(
185 () => ({
186 canvas: () => sceneRef.current?.canvas() ?? null,
187 drawFrame: (k: number, size?: { width: number; height: number }) =>
188 sceneRef.current?.drawFrame(k, size),
189 }),
190 [],
191 );
192 const movieOverlay: MovieOverlay = useMemo(
193 () => ({
194 title: `${describePlate(spec)}, ${method.shortName}`,
195 note: isMode
196 ? `mode ${mode + 1}, ${result.modes.freq[mode].toFixed(0)} Hz`
197 : `displacement ×${exaggeration}`,
198 legend:
199 frames && colorRange && !isMode
200 ? {
201 label: q.label,
202 unit: q.unit,
203 min: colorRange.min,
204 max: colorRange.max,
205 scale: q.scale,
206 colormap: q.symmetric ? "bwr" : "viridis",
207 }
208 : undefined,
209 }),
210 [spec, method.shortName, exaggeration, frames, colorRange, q, isMode, mode, result.modes.freq],
211 );
212 const modeLines = useMemo(
213 () =>
214 Array.from(result.modes.freq)
215 .filter((_, k) => result.modes.audible[k])
216 .slice(0, 40),
217 [result.modes],
218 );
220 return (
221 <div className="results">
222 <div className="results-toolbar sound-bar">
223 <AudioPlayer
224 p={result.audio.p}
225 fs={result.audio.fs}
226 normalizeDbfs={result.audio.normalize_dbfs}
227 label="sound"
228 filename={`plate-${method.id}`}
229 />
230 </div>
232 <div className="pl-scene">
233 <div className="pl-plot-title">The plate on its thread</div>
234 <Plate3DView
235 ref={sceneRef}
236 result={result}
237 spec={spec}
238 frameIndex={frameIndex}
239 frames={deformFrames}
240 colorFrames={frames}
241 colorRange={colorRange}
242 colormap={q.symmetric ? "bwr" : "viridis"}
243 scale={sceneScale}
244 width={fieldWidth}
245 height={Math.round(fieldWidth * 0.7)}
246 />
247 <div className="pl-scene-controls">
248 <label>
249 displacement ×{exaggeration}
250 <input
251 type="range"
252 min={0}
253 max={5}
254 step={0.05}
255 value={Math.log10(Math.max(1, exaggeration))}
256 onChange={(e) => setExaggeration(Math.round(10 ** Number(e.target.value)))}
257 disabled={isMode}
258 />
259 </label>
260 <span className="pl-muted">drag to orbit, wheel to zoom</span>
261 </div>
262 <MovieExport
263 source={movieSource}
264 frameTimes={isMode ? new Float64Array(0) : result.frames.t}
265 audio={audioPrep}
266 overlay={movieOverlay}
267 aspect={0.7}
268 filename={`plate-${method.id}`}
269 restoreFrame={frameIndex}
270 onRecording={(on) => on && player.setPlaying(false)}
271 />
272 <div className="pl-small pl-muted">
273 The hammer's tip sits at the strike point on the near face; the thread rises from the
274 hole. The surface is displaced by{" "}
275 {isMode ? "the chosen mode shape" : "the displacement, exaggerated by the slider"}, and
276 coloured by the quantity chosen below.
277 </div>
278 </div>
280 {frames && !isMode && (
281 <FramePlayer {...player} times={result.frames.t} timeScale={1e6} timeUnit="µs" />
282 )}
284 <div className="results-toolbar">
285 <label className="pl-small pl-muted">show</label>
286 <select
287 className="pl-select"
288 style={{ width: "auto" }}
289 value={quantity}
290 onChange={(e) => setQuantity(e.target.value as Quantity)}
291 >
292 {QUANTITIES.map((x) => (
293 <option key={x.id} value={x.id}>
294 {x.label}
295 </option>
296 ))}
297 </select>
298 {isMode && (
299 <label className="pl-small pl-muted">
300 mode{" "}
301 <select
302 className="pl-select"
303 style={{ width: "auto" }}
304 value={mode}
305 onChange={(e) => setMode(Number(e.target.value))}
306 >
307 {Array.from(result.modes.freq).map((f, k) => (
308 <option key={k} value={k}>
309 {k + 1}: {f < 20 ? `${f.toFixed(2)} Hz (rigid)` : `${f.toFixed(1)} Hz`}
310 </option>
311 ))}
312 </select>
313 </label>
314 )}
315 {frames && !isMode && (
316 <label className="pl-small pl-muted">
317 colour range{" "}
318 <select
319 className="pl-select"
320 style={{ width: "auto" }}
321 value={rangeMode}
322 onChange={(e) => setRangeMode(e.target.value as "global" | "frame")}
323 >
324 <option value="global">fixed over all frames</option>
325 <option value="frame">rescaled each frame</option>
326 </select>
327 </label>
328 )}
329 </div>
330 <FieldView
331 frames={frames}
332 field={field}
333 frameIndex={frameIndex}
334 Lx={W}
335 Ly={H}
336 title={isMode ? `Mode ${mode + 1}, ${result.modes.freq[mode].toFixed(1)} Hz` : q.label}
337 unit={q.unit}
338 scale={q.scale}
339 symmetric={q.symmetric}
340 range={isMode ? "frame" : rangeMode}
341 markers={markers}
342 width={fieldWidth}
343 caption={captionFor(quantity, result)}
344 />
346 <ModeTable
347 result={result}
348 selected={isMode ? mode : null}
349 onSelect={(k) => {
350 setMode(k);
351 setQuantity("mode");
352 }}
353 />
355 <ContactPlots result={result} spec={spec} />
357 <LinePlot
358 title={`Displacement at the probe (${(result.probe.x * 1000).toFixed(1)}, ${(result.probe.y * 1000).toFixed(1)}) mm`}
359 series={[{ x: result.t, y: result.probe.w, label: "w" }]}
360 yScale={1e6}
361 defaultXRange={[0, Math.min(result.t[result.t.length - 1], 0.02)]}
362 xLabel="time [s]"
363 yLabel="w [µm]"
364 yUnit="µm"
365 cursorX={tFrame}
366 onClick={onPlotClick}
367 height={170}
368 {...axis}
369 />
370 <LinePlot
371 title="Surface stress at the probe"
372 series={[
373 { x: result.t, y: result.probe.vm, label: "von Mises" },
374 { x: result.t, y: result.probe.sp, label: "principal (signed)", color: "#d1495b" },
375 ]}
376 yScale={1e-6}
377 defaultXRange={[0, Math.min(result.t[result.t.length - 1], 0.02)]}
378 xLabel="time [s]"
379 yLabel="σ [MPa]"
380 yUnit="MPa"
381 cursorX={tFrame}
382 onClick={onPlotClick}
383 height={170}
384 {...axis}
385 />
386 <div className="pl-small pl-muted">
387 The first 20 ms are shown; drag to zoom, double-click to reset. The whole{" "}
388 {result.t[result.t.length - 1].toFixed(1)} s record is there.
389 </div>
391 <LinePlot
392 title="Amplitude spectrum of the sound (Hann window)"
393 series={[
394 { x: result.spectrum.f, y: result.spectrum.p, label: "pressure [Pa]" },
395 ...modeLines.map((f) => ({
396 x: Float64Array.of(f, f),
397 y: Float64Array.of(1e-12, 1),
398 color: "rgba(42,157,143,0.45)",
399 width: 1,
400 dash: [3, 3],
401 })),
402 ]}
403 defaultXRange={[0, result.info.f_max]}
404 logY
405 xLabel="frequency [Hz]"
406 yLabel="amplitude"
407 height={190}
408 {...spectrumAxis}
409 />
410 <div className="pl-small pl-muted">
411 Dashed lines mark the audible modes' frequencies. Resolution{" "}
412 {(1 / (result.t[result.t.length - 1] - result.t[0])).toFixed(2)} Hz from a{" "}
413 {(result.t[result.t.length - 1] - result.t[0]).toFixed(2)} s record; modes the strike does
414 not reach show no peak.
415 </div>
416 <LinePlot
417 title="Amplitude spectrum of the probe displacement"
418 series={[{ x: result.spectrum.f, y: result.spectrum.w, label: "w [m]" }]}
419 defaultXRange={[0, result.info.f_max]}
420 logY
421 xLabel="frequency [Hz]"
422 yLabel="amplitude"
423 height={150}
424 {...spectrumAxis}
425 />
427 <div>
428 <div className="pl-plot-title">Pressure at the microphone</div>
429 <LinePlot
430 series={[{ x: audioT, y: result.audio.p, label: "pressure" }]}
431 defaultXRange={[0, Math.min(audioT[audioT.length - 1], 0.005)]}
432 xLabel="time [s]"
433 yLabel="p [Pa]"
434 yUnit="Pa"
435 height={120}
436 {...axis}
437 />
438 <div className="pl-small pl-muted">
439 Compact-source estimate {spec.mic.distance * 1000} mm from the plate on its normal, with
440 the front/back cancellation of an unbaffled plate below{" "}
441 {(result.info.critical_frequency / 1000).toFixed(1)} kHz. The first 5 ms are shown. Peak{" "}
442 {result.audio.peak_pa.toExponential(2)} Pa before normalization.
443 </div>
444 </div>
446 <details>
447 <summary className="pl-small">What this result idealizes</summary>
448 <ValidityNotes method={method} result={result} />
449 </details>
450 </div>
451 );
454/** The stock, for the movie's title: "30 × 30 × 1 mm, 6061-T6 aluminium". */
455function describePlate(spec: ProblemSpec): string {
456 const p = spec.plate;
457 const mm = (x: number) => Number((x * 1000).toPrecision(3)).toString();
458 return `${mm(p.width)} × ${mm(p.height)} × ${mm(p.thickness)} mm, ${spec.material.name}`;
461function captionFor(q: Quantity, result: PlateResult): string {
462 switch (q) {
463 case "w":
464 return "The sum of the audible modes: what radiates. Positive along the plate normal, towards the hammer.";
465 case "w_total":
466 return result.info.n_rigid > 0
467 ? "With the three rigid-body modes the finite-element method keeps: the recoil and the rotation the hammer leaves the whole plate with."
468 : "This method has no rigid-body modes, so this is the same field as the audible one.";
469 case "vm":
470 case "sp":
471 return "Surface stress at z = h/2 from the modal curvatures; the opposite face carries the opposite sign.";
472 case "env_vm":
473 case "env_sp":
474 return "Peak over the saved frames, which cover the strike transient; a sample of the true peak.";
475 case "mode":
476 return "The mass-normalized shape scaled to unit peak; the table below gives its part in this strike.";
477 }
480function firstAudible(result: PlateResult): number {
481 const k = result.modes.audible.findIndex((a) => a);
482 return k < 0 ? 0 : k;
485/** Each shape divided by its own peak, so that all are drawn at the same amplitude. */
486function normalizeShapes(shapes: FieldFrames): FieldFrames {
487 const n = shapes.nx * shapes.ny;
488 const data = new Float64Array(shapes.data.length);
489 for (let k = 0; k < shapes.nf; k++) {
490 let peak = 0;
491 for (let i = k * n; i < (k + 1) * n; i++) peak = Math.max(peak, Math.abs(shapes.data[i]));
492 const g = peak > 0 ? 1 / peak : 1;
493 for (let i = k * n; i < (k + 1) * n; i++) data[i] = shapes.data[i] * g;
494 }
495 return { ...shapes, data };
498/**
499 * The frame of largest displacement. The player starts here so a run that
500 * has just finished shows the plate in motion.
501 */
502function peakFrame(f: FieldFrames | undefined): number {
503 if (!f) return 0;
504 const n = f.nx * f.ny;
505 let best = 0;
506 let bestv = -1;
507 for (let k = 0; k < f.nf; k++) {
508 let v = 0;
509 for (let i = k * n; i < (k + 1) * n; i++) {
510 const a = Math.abs(f.data[i]);
511 if (a > v) v = a;
512 }
513 if (v > bestv) {
514 bestv = v;
515 best = k;
516 }
517 }
518 return best;
↑↓ move↵openescclose