/ proof-of-concept / steelsim41
Sign in
proof-of-concept / steelsim41
steelsim41 / tests / design.test.ts
132 lines · 7.6 KBBlameHistoryRaw
1// Tests of the design script: compilation, the checks, the helpers, the
2// evaluation on the grid, and the presets.
4import { test } from "node:test";
5import assert from "node:assert/strict";
6import { DesignError, HELPERS, compileDesign, evaluatePlate, noise2d, profileStats, removePlane } from "../src/design.ts";
7import { PRESETS } from "../src/presets.ts";
8import { countInside, insideNode, mountPatch, nodeWeight, nodeX, nodeY, thicknessRange } from "../src/params.ts";
10const minimal = `return { material: { E: 170e9, nu: 0.29, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3 };`;
12test("a minimal script compiles with defaults filled in", () => {
13 const d = compileDesign(minimal);
14 assert.equal(d.name, "Untitled design");
15 assert.equal(d.inside, null);
16 assert.equal(d.mount, null);
17 assert.equal(d.thickness(0, 0, 0, 0), 1e-3);
18 assert.equal(d.profile(0.1, 0.05, 0, 0), 0);
19 assert.deepEqual(d.loss, { sigma0: 1.15, sigma1: 5e-4, sigmaIn: 20 });
20});
22test("the helpers are in scope and lengths are SI", () => {
23 const d = compileDesign(`return { name: "x", material: { E: 1e11, nu: 0.3, rho: 8000 }, bounds: { width: 10 * inch, height: 100 * mm }, thickness: (x, y, r) => lerp(2 * mm, 1 * mm, clamp(r / 0.1, 0, 1)), profile: (x, y, r) => dome(r, 0.05, 0.01) + bump(r, 0.02, 0.001) + bow(x, 0.254, 0.002) };`);
24 assert.ok(Math.abs(d.Lx - 0.254) < 1e-12 && Math.abs(d.Ly - 0.1) < 1e-12);
25 assert.ok(Math.abs(d.thickness(0, 0, 0, 0) - 2e-3) < 1e-12);
26 assert.ok(Math.abs(d.thickness(0, 0, 0.1, 0) - 1e-3) < 1e-12);
27 assert.ok(Math.abs(d.profile(0, 0, 0, 0) - (0.01 + 0.001 + 0.002)) < 1e-12);
28 assert.ok(Math.abs(d.profile(0, 0, 0.06, 0) - (0.002 + 0.001 * Math.exp(-9))) < 1e-15);
29 assert.equal(HELPERS.smoothstep(0, 1, -1), 0);
30 assert.equal(HELPERS.smoothstep(0, 1, 2), 1);
31 assert.equal(HELPERS.smoothstep(0, 1, 0.5), 0.5);
32});
34test("scripts that do not describe a plate are rejected with a message", () => {
35 const bad: [string, RegExp][] = [
36 [`return 5;`, /must return an object/],
37 [`this is not javascript`, /script error/],
38 [`throw new Error("boom");`, /boom/],
39 [`return { bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3 };`, /material/],
40 [`return { material: { E: 170e9, nu: 0.6, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3 };`, /nu/],
41 [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3 }, thickness: 1e-3 };`, /bounds.height/],
42 [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 } };`, /thickness/],
43 [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: "thin" };`, /thickness/],
44 [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, inside: 5 };`, /inside/],
45 [`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, mount: { stiffness: 1e4 } };`, /mount.radius/],
46 ];
47 for (const [src, re] of bad) assert.throws(() => compileDesign(src), (e: unknown) => e instanceof DesignError && re.test(e.message), src);
48});
50test("evaluation rejects a thickness that is not positive and a profile that is not finite", () => {
51 const zero = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: (x) => x };`);
52 assert.throws(() => evaluatePlate(zero, 0.02), /thickness must be positive/);
53 const nan = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, profile: (x, y) => Math.sqrt(x) };`);
54 assert.throws(() => evaluatePlate(nan, 0.02), /profile returned NaN/);
55 const thrower = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, inside: () => { throw new Error("no"); } };`);
56 assert.throws(() => evaluatePlate(thrower, 0.02), /inside threw/);
57 const empty = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, inside: () => false };`);
58 assert.throws(() => evaluatePlate(empty, 0.02), /no grid cells/);
59});
61test("a circle with a hole excludes the center and the corners", () => {
62 const d = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.2, height: 0.2 }, thickness: 1e-3, inside: circle(0.2, 0.03) };`);
63 const pl = evaluatePlate(d, 0.01);
64 const g = pl.grid;
65 assert.equal(g.nx, 21);
66 const c = (g.nx - 1) / 2;
67 assert.ok(!insideNode(g, c, c), "center node lies in the hole");
68 assert.ok(!insideNode(g, 0, 0), "corner node lies outside the disc");
69 assert.ok(insideNode(g, c, 3), "a node on the radius belongs to the disc");
70 let weight = 0;
71 for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) weight += nodeWeight(g, i, j);
72 const area = weight * g.hx * g.hy;
73 const exact = Math.PI * (0.1 ** 2 - 0.015 ** 2);
74 assert.ok(Math.abs(area / exact - 1) < 0.05, `the ${countInside(g)} nodes cover ${area} vs ${exact}`);
75 assert.equal(thicknessRange(pl).max, 1e-3);
76});
78test("the profile is made plane-free and the mount patch is found", () => {
79 const d = compileDesign(`return { material: { E: 170e9, nu: 0.3, rho: 7900 }, bounds: { width: 0.3, height: 0.2 }, thickness: 1e-3, profile: (x, y) => 0.01 + 0.05 * x - 0.02 * y + 0.001 * x * x, mount: { radius: 0.02, stiffness: 1e4, damping: 1 } };`);
80 const pl = evaluatePlate(d, 0.01);
81 const g = pl.grid;
82 let s = 0, sx = 0, sy = 0;
83 for (let j = 0; j < g.ny; j++) {
84 for (let i = 0; i < g.nx; i++) {
85 const wgt = nodeWeight(g, i, j), v = pl.w0[j * g.nx + i];
86 s += wgt * v; sx += wgt * nodeX(g, i) * v; sy += wgt * nodeY(g, j) * v;
87 }
88 }
89 assert.ok(Math.abs(s) < 1e-12 && Math.abs(sx) < 1e-12 && Math.abs(sy) < 1e-12, "mean plane removed");
90 const stats = profileStats(pl);
91 assert.ok(stats.peakToPeak > 0 && stats.peakToPeak < 1e-4, `peak to peak ${stats.peakToPeak}`);
92 const mp = mountPatch(pl)!;
93 assert.ok(mp.nodes.length >= 9 && mp.nodes.length <= 21, `mount patch has ${mp.nodes.length} nodes`);
94 assert.ok(mp.weight > 0);
95});
97test("removePlane leaves a plane-free field unchanged", () => {
98 const g = { nx: 7, ny: 5, hx: 0.05, hy: 0.05, n: 35 };
99 const f = new Float64Array(g.n);
100 for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) f[j * g.nx + i] = nodeX(g, i) ** 2 - 0.0125;
101 const before = Float64Array.from(f);
102 removePlane(f, g);
103 // The parabola x^2 has a nonzero mean but no tilt; only the mean should move.
104 let shift = f[0] - before[0];
105 for (let k = 0; k < g.n; k++) assert.ok(Math.abs(f[k] - before[k] - shift) < 1e-12);
106});
108test("noise2d is deterministic with unit RMS", () => {
109 const a = noise2d(7, 0.05), b = noise2d(7, 0.05), c = noise2d(8, 0.05);
110 assert.equal(a(0.01, 0.02), b(0.01, 0.02));
111 assert.notEqual(a(0.01, 0.02), c(0.01, 0.02));
112 let s2 = 0, n = 0;
113 for (let x = -0.5; x < 0.5; x += 0.01) for (let y = -0.5; y < 0.5; y += 0.01) { s2 += a(x, y) ** 2; n++; }
114 const rms = Math.sqrt(s2 / n);
115 assert.ok(Math.abs(rms - 1) < 0.2, `rms ${rms}`);
116});
118test("every preset compiles and evaluates on the default grid", () => {
119 for (const p of PRESETS) {
120 const d = compileDesign(p.source);
121 const pl = evaluatePlate(d, 0.008);
122 const hr = thicknessRange(pl);
123 assert.ok(hr.min > 0 && hr.max < 5e-3, `${p.id}: thickness ${hr.min}..${hr.max}`);
124 assert.ok(countInside(pl.grid) > 100, `${p.id}: ${countInside(pl.grid)} nodes`);
125 const stats = profileStats(pl);
126 assert.ok(stats.peakToPeak < 0.05, `${p.id}: peak to peak ${stats.peakToPeak}`);
127 if (pl.mount) {
128 const mp = mountPatch(pl)!;
129 assert.ok(mp.nodes.length > 1, `${p.id}: mount patch has ${mp.nodes.length} nodes`);
130 }
131 }
132});
↑↓ move↵openescclose