/ proof-of-concept / steelsim41
Sign in
proof-of-concept / steelsim41
steelsim41 / tests / design.test.ts
246 lines · 11.1 KBBlameHistoryRaw
1// Tests of the design script: the functions a script defines for itself,
2// compilation and the checks, the evaluation on the grid, and the presets.
4import { test } from "node:test";
5import assert from "node:assert/strict";
6import { DesignError, compileDesign, evaluatePlate, profileStats, removePlane, runDesignScript } from "../src/design.ts";
7import { PRESETS } from "../src/presets.ts";
8import { countInside, insideNode, mountPatch, nodeWeight, nodeX, nodeY, thicknessRange } from "../src/params.ts";
10const minimal = `design.material.E = 170e9; design.material.nu = 0.29; design.material.rho = 7900;
11design.bounds.width = 0.3; design.bounds.height = 0.2;
12design.thickness = 1e-3;`;
14/** A scalar from the workspace. */
15function num(vars: Record<string, unknown>, name: string): number {
16 const v = vars[name] as number | { data: Float64Array };
17 return typeof v === "number" ? v : v.data[0];
19function data(vars: Record<string, unknown>, name: string): Float64Array {
20 return (vars[name] as { data: Float64Array }).data;
23test("a minimal script compiles with defaults filled in", () => {
24 const d = compileDesign(minimal);
25 assert.equal(d.name, "Untitled design");
26 assert.equal(d.hasInside, false);
27 assert.equal(d.mount, null);
28 assert.equal(d.thicknessConst, 1e-3);
29 assert.equal(d.profileConst, 0);
30 assert.deepEqual(d.loss, { sigma0: 1.15, sigma1: 5e-4, sigmaIn: 20 });
31 assert.ok(Math.abs(d.Lx - 0.3) < 1e-12 && Math.abs(d.Ly - 0.2) < 1e-12);
32});
34test("a script may define its own functions, and they are vectorized", () => {
35 const v = runDesignScript(`
36mm = 1e-3;
37s = [smoothstep(0, 1, -1), smoothstep(0, 1, 2), smoothstep(0, 1, 0.5)];
38p0 = dome(0, 0.05, 0.01);
39p1 = dome(0.06, 0.05, 0.01);
40[X, Y] = meshgrid(-0.1:0.05:0.1, -0.1:0.05:0.1);
41dd = dents(X, Y, [0 0.05], [0 0], [0.02 0.02], [1e-3 -1e-3]);
42u = seededrand(5, 3); u2 = seededrand(5, 3);
44function v = ramp(a, b, x)
45v = min(max((x - a) ./ (b - a), 0), 1);
46end
47function v = smoothstep(e0, e1, x)
48t = ramp(e0, e1, x);
49v = t.^2 .* (3 - 2*t);
50end
51function v = dome(d, radius, height)
52v = 0.5 * height * (1 + cos(pi * d / radius)) .* (d < radius);
53end
54function v = dents(x, y, cx, cy, radius, height)
55v = zeros(size(x));
56for k = 1:numel(cx)
57 v = v + height(k) * exp(-((x - cx(k)).^2 + (y - cy(k)).^2) / radius(k)^2);
58end
59end
60function u = seededrand(seed, n)
61s = floor(seed);
62u = zeros(1, n);
63for k = 1:n
64 s = mod(s * 1664525 + 1013904223, 4294967296);
65 u(k) = s / 4294967296;
66end
67end
68`);
69 assert.deepEqual(Array.from(data(v, "s")), [0, 1, 0.5], "one local function may call another");
70 assert.ok(Math.abs(num(v, "p0") - 0.01) < 1e-12);
71 assert.equal(num(v, "p1"), 0, "the dome vanishes beyond its rim");
72 const dd = data(v, "dd"); // 5 x 5, column-major; the center sits at index 12
73 assert.ok(Math.abs(dd[12] - (1e-3 - 1e-3 * Math.exp(-(0.05 ** 2) / 0.02 ** 2))) < 1e-15, "two dents of opposite sign");
74 assert.deepEqual(Array.from(data(v, "u")), Array.from(data(v, "u2")), "the seeded generator is deterministic");
75 assert.ok(data(v, "u").every((x) => x >= 0 && x < 1));
76});
78test("a handle may call a function the script defines below it", () => {
79 // The trailer that samples the fields is appended after the script's own
80 // functions, and it calls the handles from inside a function of its own.
81 const d = compileDesign(`design.material.E = 170e9; design.material.nu = 0.29; design.material.rho = 7900;
82design.bounds.width = 0.2; design.bounds.height = 0.2;
83t0 = 1e-3;
84design.thickness = @(x, y, r, theta) t0 * (1 + taper(r));
85design.inside = @(x, y, r, theta) r <= 0.1;
87function v = taper(r)
88v = min(max(r / 0.1, 0), 1);
89end
90`);
91 const pl = evaluatePlate(d, 0.01);
92 const hr = thicknessRange(pl);
93 assert.ok(Math.abs(hr.min - 1e-3) < 2e-4 && Math.abs(hr.max - 2e-3) < 1e-4, `thickness ${hr.min}..${hr.max}`);
94});
96test("scripts that do not describe a plate are rejected with a message", () => {
97 const bad: [string, RegExp][] = [
98 [`x = 5;`, /struct named design/],
99 [`design = 5;`, /design must be a struct/],
100 [`x = (1 + ;`, /line 1/],
101 [`error('boom');`, /boom/],
102 [`design.bounds.width = 0.3; design.bounds.height = 0.2; design.thickness = 1e-3;`, /material/],
103 [`design.material.E = 170e9; design.material.nu = 0.6; design.material.rho = 7900; design.bounds.width = 0.3; design.bounds.height = 0.2; design.thickness = 1e-3;`, /nu/],
104 [`design.material.E = 170e9; design.material.nu = 0.3; design.material.rho = 7900; design.bounds.width = 0.3; design.thickness = 1e-3;`, /bounds.height/],
105 [`design.material.E = 170e9; design.material.nu = 0.3; design.material.rho = 7900; design.bounds.width = 0.3; design.bounds.height = 0.2;`, /thickness/],
106 [`${minimal} design.thickness = 'thin';`, /thickness must be a number or a function handle/],
107 [`${minimal} design.thickness = -1;`, /thickness must be positive/],
108 [`${minimal} design.inside = 5;`, /inside must be a function handle/],
109 [`${minimal} design.mount.stiffness = 1e4;`, /mount.radius/],
110 [`${minimal} design.loss = 3;`, /loss must be a struct/],
111 ];
112 for (const [src, re] of bad) assert.throws(() => compileDesign(src), (e: unknown) => e instanceof DesignError && re.test(e.message), src);
113});
115test("evaluation rejects fields that are not positive, not finite, not vectorized, or that throw", () => {
116 const zero = compileDesign(`${minimal} design.thickness = @(x, y, r, theta) x;`);
117 assert.throws(() => evaluatePlate(zero, 0.02), /thickness must be positive/);
118 const nan = compileDesign(`${minimal} design.profile = @(x, y, r, theta) 0*x./0;`);
119 assert.throws(() => evaluatePlate(nan, 0.02), /profile is NaN/);
120 const shape = compileDesign(`${minimal} design.profile = @(x, y, r, theta) [1 2 3];`);
121 assert.throws(() => evaluatePlate(shape, 0.02), /profile returned a 1 x 3 array/);
122 const thrower = compileDesign(`${minimal} design.inside = @(x, y, r, theta) no_such_function(x);`);
123 assert.throws(() => evaluatePlate(thrower, 0.02), /inside: .*no_such_function/);
124 const empty = compileDesign(`${minimal} design.inside = @(x, y, r, theta) false & x;`);
125 assert.throws(() => evaluatePlate(empty, 0.02), /no grid cells/);
126 const scalarFn = evaluatePlate(compileDesign(`${minimal} design.thickness = @(x, y, r, theta) 2e-3;`), 0.02);
127 assert.equal(thicknessRange(scalarFn).min, 2e-3, "a handle returning a scalar is spread over the grid");
128});
130test("a circle with a hole excludes the center and the corners", () => {
131 const d = compileDesign(`design.material.E = 170e9; design.material.nu = 0.3; design.material.rho = 7900;
132design.bounds.width = 0.2; design.bounds.height = 0.2; design.thickness = 1e-3;
133design.inside = @(x, y, r, theta) (r <= 0.1) & (r >= 0.015);`);
134 const pl = evaluatePlate(d, 0.01);
135 const g = pl.grid;
136 assert.equal(g.nx, 21);
137 const c = (g.nx - 1) / 2;
138 assert.ok(!insideNode(g, c, c), "center node lies in the hole");
139 assert.ok(!insideNode(g, 0, 0), "corner node lies outside the disc");
140 assert.ok(insideNode(g, c, 3), "a node on the radius belongs to the disc");
141 let weight = 0;
142 for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) weight += nodeWeight(g, i, j);
143 const area = weight * g.hx * g.hy;
144 const exact = Math.PI * (0.1 ** 2 - 0.015 ** 2);
145 assert.ok(Math.abs(area / exact - 1) < 0.05, `the ${countInside(g)} nodes cover ${area} vs ${exact}`);
146 assert.equal(thicknessRange(pl).max, 1e-3);
147});
149test("fields are sampled at the right nodes (meshgrid orientation)", () => {
150 const d = compileDesign(`${minimal} design.thickness = @(x, y, r, theta) 10 + x + 10*y;`);
151 const pl = evaluatePlate(d, 0.05);
152 const g = pl.grid;
153 assert.ok(g.nx !== g.ny, "the grid is not square, so a transposed sampling would show");
154 for (let j = 0; j < g.ny; j++) for (let i = 0; i < g.nx; i++) {
155 const want = 10 + nodeX(g, i) + 10 * nodeY(g, j);
156 assert.ok(Math.abs(pl.h[j * g.nx + i] - want) < 1e-12, `node ${i}, ${j}: ${pl.h[j * g.nx + i]} vs ${want}`);
157 }
158});
160test("the profile is made plane-free and the mount patch is found", () => {
161 const d = compileDesign(`${minimal}
162design.profile = @(x, y, r, theta) 0.01 + 0.05*x - 0.02*y + 0.001*x.^2;
163design.mount.radius = 0.02; design.mount.stiffness = 1e4; design.mount.damping = 1;`);
164 const pl = evaluatePlate(d, 0.01);
165 const g = pl.grid;
166 let s = 0, sx = 0, sy = 0;
167 for (let j = 0; j < g.ny; j++) {
168 for (let i = 0; i < g.nx; i++) {
169 const wgt = nodeWeight(g, i, j), v = pl.w0[j * g.nx + i];
170 s += wgt * v; sx += wgt * nodeX(g, i) * v; sy += wgt * nodeY(g, j) * v;
171 }
172 }
173 assert.ok(Math.abs(s) < 1e-12 && Math.abs(sx) < 1e-12 && Math.abs(sy) < 1e-12, "mean plane removed");
174 const stats = profileStats(pl);
175 assert.ok(stats.peakToPeak > 0 && stats.peakToPeak < 1e-4, `peak to peak ${stats.peakToPeak}`);
176 const mp = mountPatch(pl)!;
177 assert.ok(mp.nodes.length >= 9 && mp.nodes.length <= 21, `mount patch has ${mp.nodes.length} nodes`);
178 assert.ok(mp.weight > 0);
179});
181test("removePlane leaves a plane-free field unchanged", () => {
182 const g = { nx: 7, ny: 5, hx: 0.05, hy: 0.05, n: 35 };
183 const f = new Float64Array(g.n);
184 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;
185 const before = Float64Array.from(f);
186 removePlane(f, g);
187 // The parabola x^2 has a nonzero mean but no tilt; only the mean should move.
188 const shift = f[0] - before[0];
189 for (let k = 0; k < g.n; k++) assert.ok(Math.abs(f[k] - before[k] - shift) < 1e-12);
190});
192test("the presets' noise field is deterministic with unit RMS", () => {
193 // The same generator the dented crash uses, as a script-local function
194 // returning a handle that calls another script-local function.
195 const v = runDesignScript(`
196a = noise2d(7, 0.05); b = noise2d(7, 0.05); c = noise2d(8, 0.05);
197[X, Y] = meshgrid(-0.5:0.01:0.49, -0.5:0.01:0.49);
198A = a(X, Y); B = b(X, Y); C = c(X, Y);
199same = isequal(A, B); different = ~isequal(A, C);
200rms = sqrt(mean(A(:).^2));
202function u = seededrand(seed, n)
203s = floor(seed);
204u = zeros(1, n);
205for k = 1:n
206 s = mod(s * 1664525 + 1013904223, 4294967296);
207 u(k) = s / 4294967296;
208end
209end
210function f = noise2d(seed, len)
211n = 64;
212u = reshape(seededrand(seed, 5*n), 5, n);
213kx = sqrt(-2*log(1 - u(1,:))) .* cos(2*pi*u(2,:)) * sqrt(2) / len;
214ky = sqrt(-2*log(1 - u(3,:))) .* cos(2*pi*u(4,:)) * sqrt(2) / len;
215ph = 2*pi*u(5,:);
216f = @(x, y) noisesum(kx, ky, ph, x, y);
217end
218function v = noisesum(kx, ky, ph, x, y)
219v = zeros(size(x));
220for k = 1:numel(kx)
221 v = v + cos(kx(k) * x + ky(k) * y + ph(k));
222end
223v = v / sqrt(numel(kx) / 2);
224end
225`);
226 assert.equal(v.same, true);
227 assert.equal(v.different, true);
228 const rms = num(v, "rms");
229 assert.ok(Math.abs(rms - 1) < 0.2, `rms ${rms}`);
230});
232test("every preset compiles and evaluates on the default grid", () => {
233 for (const p of PRESETS) {
234 const d = compileDesign(p.source);
235 const pl = evaluatePlate(d, 0.008);
236 const hr = thicknessRange(pl);
237 assert.ok(hr.min > 0 && hr.max < 5e-3, `${p.id}: thickness ${hr.min}..${hr.max}`);
238 assert.ok(countInside(pl.grid) > 100, `${p.id}: ${countInside(pl.grid)} nodes`);
239 const stats = profileStats(pl);
240 assert.ok(stats.peakToPeak < 0.05, `${p.id}: peak to peak ${stats.peakToPeak}`);
241 if (pl.mount) {
242 const mp = mountPatch(pl)!;
243 assert.ok(mp.nodes.length > 1, `${p.id}: mount patch has ${mp.nodes.length} nodes`);
244 }
245 }
246});
↑↓ move↵openescclose