/ proof-of-concept / cymsimweb
Sign in
proof-of-concept / cymsimweb
cymsimweb / src / config / yaml.ts
126 lines · 8.6 KBCodeBlameHistory
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 1// YAML <-> SimulationConfig: parsing with defaults for omitted fields, reporting of unknown keys
2// and wrong types, the validation rules of the Python package, and serialization in the same
3// field order the Python package writes.
4import yaml from "js-yaml";
5import { defaultConfig, defaultHit, defaultMallet, defaultStick, type Exciter, type Hit, type SimulationConfig } from "./schema.ts";
7export interface Issue { path: string; message: string }
9const SECTIONS = ["material", "profile", "imperfections", "mesh", "hits", "listener", "numerics", "output"] as const;
11function isObject(x: unknown): x is Record<string, unknown> { return typeof x === "object" && x !== null && !Array.isArray(x); }
13/** Fill one flat section from raw data, reporting unknown keys and wrong types. */
14function fillSection<T extends object>(target: T, raw: unknown, path: string, issues: Issue[]): T {
15 if (raw === undefined || raw === null) return target;
16 if (!isObject(raw)) { issues.push({ path, message: "expected a mapping" }); return target; }
17 const t = target as unknown as Record<string, unknown>;
18 for (const key of Object.keys(raw)) {
19 if (!(key in t)) { issues.push({ path: `${path}.${key}`, message: "unknown field" }); continue; }
20 const def = t[key], val = raw[key];
21 if (typeof def === "number") {
22 if (typeof val === "number" && Number.isFinite(val)) t[key] = val;
23 else issues.push({ path: `${path}.${key}`, message: "expected a number" });
24 } else if (typeof def === "boolean") {
25 if (typeof val === "boolean") t[key] = val;
26 else issues.push({ path: `${path}.${key}`, message: "expected true or false" });
27 } else if (typeof def === "string") {
28 if (typeof val === "string") t[key] = val;
29 else issues.push({ path: `${path}.${key}`, message: "expected a string" });
31 const isPoint = (x: unknown) => Array.isArray(x) && x.length === 2 && x.every((c) => typeof c === "number" && Number.isFinite(c));
32 if (Array.isArray(val) && val.every(isPoint)) t[key] = val.map((x) => (x as number[]).slice());
33 else issues.push({ path: `${path}.${key}`, message: "expected a list of points [x, y]" });
35 if (Array.isArray(val) && val.every((x) => typeof x === "number" && Number.isFinite(x))) {
36 if (key === "position" && val.length !== 3) issues.push({ path: `${path}.${key}`, message: "expected three numbers" });
37 else t[key] = val.slice();
38 } else issues.push({ path: `${path}.${key}`, message: "expected a list of numbers" });
39 }
40 }
41 return target;
44function fillExciter(raw: unknown, path: string, issues: Issue[]): Exciter {
45 if (raw === undefined) return defaultMallet();
46 if (!isObject(raw)) { issues.push({ path, message: "expected a mapping with a 'kind' key" }); return defaultMallet(); }
47 const kind = raw.kind;
48 if (kind === "stick") return fillSection(defaultStick(), raw, path, issues);
49 if (kind === "mallet") return fillSection(defaultMallet(), raw, path, issues);
50 issues.push({ path: `${path}.kind`, message: `unknown exciter kind ${JSON.stringify(kind)}; expected 'stick' or 'mallet'` });
51 return defaultMallet();
54function fillHit(raw: unknown, path: string, issues: Issue[]): Hit {
55 const hit = defaultHit();
56 if (!isObject(raw)) { issues.push({ path, message: "expected a mapping" }); return hit; }
57 const { exciter, ...rest } = raw;
58 const flat: Record<string, unknown> = { time: hit.time, r: hit.r, phi: hit.phi, velocity: hit.velocity };
59 fillSection(flat, rest, path, issues);
60 return { time: flat.time as number, r: flat.r as number, phi: flat.phi as number, velocity: flat.velocity as number,
61 exciter: fillExciter(exciter, `${path}.exciter`, issues) };
64/** Build a complete configuration from raw (parsed YAML) data; missing fields take the defaults. */
65export function normalizeConfig(raw: unknown): { config: SimulationConfig; issues: Issue[] } {
66 const issues: Issue[] = [];
67 const cfg = defaultConfig();
68 if (raw === null || raw === undefined) return { config: cfg, issues };
69 if (!isObject(raw)) { issues.push({ path: "", message: "the document must be a mapping" }); return { config: cfg, issues }; }
70 for (const key of Object.keys(raw)) if (!(SECTIONS as readonly string[]).includes(key)) issues.push({ path: key, message: "unknown section" });
71 fillSection(cfg.material, raw.material, "material", issues);
72 fillSection(cfg.profile, raw.profile, "profile", issues);
73 fillSection(cfg.imperfections, raw.imperfections, "imperfections", issues);
74 fillSection(cfg.mesh, raw.mesh, "mesh", issues);
75 fillSection(cfg.listener, raw.listener, "listener", issues);
76 fillSection(cfg.numerics, raw.numerics, "numerics", issues);
77 fillSection(cfg.output, raw.output, "output", issues);
78 if (raw.hits !== undefined) {
79 if (!Array.isArray(raw.hits)) issues.push({ path: "hits", message: "expected a list of hits" });
80 else cfg.hits = raw.hits.map((h, i) => fillHit(h, `hits[${i}]`, issues));
81 }
82 return { config: cfg, issues };
85/** The validation rules of cymsim/config.py, plus a few that the web app needs. */
86export function validateConfig(cfg: SimulationConfig): Issue[] {
87 const issues: Issue[] = [], p = cfg.profile, n = cfg.numerics;
88 if (cfg.hits.length === 0) issues.push({ path: "hits", message: "at least one hit is required" });
89 if (n.dtype !== "float32" && n.dtype !== "float64") issues.push({ path: "numerics.dtype", message: "must be 'float32' or 'float64' (the web app always runs in float32)" });
c24896fTime step from the global stability bound, as in cymsim; Resume falls back to the element bound for older runs; accept output.probesJeremy Magland 90 if (n.stability_bound !== "global" && n.stability_bound !== "element") issues.push({ path: "numerics.stability_bound", message: "must be 'global' or 'element'" });
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 91 if (!(n.cfl_safety > 0 && n.cfl_safety <= 1)) issues.push({ path: "numerics.cfl_safety", message: "must lie in (0, 1]" });
92 if (!(cfg.mesh.element_size > 0 && cfg.mesh.element_size <= p.radius / 2)) issues.push({ path: "mesh.element_size", message: "must be positive and well below the radius" });
93 if (!(n.duration > 0)) issues.push({ path: "numerics.duration", message: "must be positive" });
94 if (!(n.fs >= 8000 && n.fs <= 192000)) issues.push({ path: "numerics.fs", message: "must lie between 8000 and 192000 Hz" });
c24896fTime step from the global stability bound, as in cymsim; Resume falls back to the element bound for older runs; accept output.probesJeremy Magland 95 if (cfg.output.probes.length) issues.push({ path: "output.probes", message: "probe records are not supported by the web app (the Python package writes them)" });
13f4dbdexport .cymsim fileJeremy Magland 96 if (!(cfg.output.displacement_rate >= 0)) issues.push({ path: "output.displacement_rate", message: "must be non-negative" });
1e29264cymsimweb: WebGPU companion of cymsim with YAML/form editor, live spectrogram and playbackJeremy Magland 97 if (!(p.radius > 0 && p.h_rim > 0 && p.h_shoulder > 0 && p.h_bell > 0)) issues.push({ path: "profile", message: "radius and thicknesses must be positive" });
98 if (!(p.bell_top_radius < p.bell_shoulder_radius && p.bell_shoulder_radius < p.radius)) issues.push({ path: "profile", message: "need bell_top_radius < bell_shoulder_radius < radius" });
99 if (!(cfg.material.E > 0 && cfg.material.rho > 0 && cfg.material.nu > -1 && cfg.material.nu < 0.5)) issues.push({ path: "material", message: "E and rho must be positive and nu in (-1, 0.5)" });
100 cfg.hits.forEach((h, i) => {
101 if (h.time < 0) issues.push({ path: `hits[${i}].time`, message: "must be non-negative" });
102 if (!(h.velocity > 0)) issues.push({ path: `hits[${i}].velocity`, message: "must be positive (the exciter approaches from above)" });
103 const ex = h.exciter;
104 const extent = ex.kind === "stick" ? ex.patch_length / 2 : ex.radius;
105 if (h.r + extent >= p.radius - cfg.mesh.element_size) issues.push({ path: `hits[${i}].r`, message: `contact patch reaches the rim (r = ${h.r}, extent = ${extent})` });
106 if (h.r < 0) issues.push({ path: `hits[${i}].r`, message: "must be non-negative" });
107 if (!(ex.mass > 0)) issues.push({ path: `hits[${i}].exciter.mass`, message: "must be positive" });
108 if (ex.kind === "stick" && !(ex.patch_points >= 1)) issues.push({ path: `hits[${i}].exciter.patch_points`, message: "must be at least 1" });
109 if (ex.kind === "mallet" && !(ex.compression > 0)) issues.push({ path: `hits[${i}].exciter.compression`, message: "must be positive" });
110 });
111 return issues;
114/** Parse a YAML document into a configuration. ``config`` is null only when the YAML itself is malformed. */
115export function parseConfigYaml(text: string): { config: SimulationConfig | null; issues: Issue[] } {
116 let raw: unknown;
117 try { raw = yaml.load(text); } catch (err) { return { config: null, issues: [{ path: "", message: `YAML: ${(err as Error).message}` }] }; }
118 const { config, issues } = normalizeConfig(raw);
119 return { config, issues: issues.concat(validateConfig(config)) };
122export function configToYaml(cfg: SimulationConfig): string {
123 return yaml.dump(cfg, { noRefs: true, lineWidth: -1, sortKeys: false });
126export function cloneConfig(cfg: SimulationConfig): SimulationConfig { return structuredClone(cfg); }
↑↓ move↵openescclose