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" });
30 } else if (Array.isArray(def)) {
31 if (Array.isArray(val) && val.every((x) => typeof x === "number" && Number.isFinite(x))) {
32 if (key === "position" && val.length !== 3) issues.push({ path: `${path}.${key}`, message: "expected three numbers" });
33 else t[key] = val.slice();
34 } else issues.push({ path: `${path}.${key}`, message: "expected a list of numbers" });
35 }
36 }
37 return target;
38}
40function fillExciter(raw: unknown, path: string, issues: Issue[]): Exciter {
41 if (raw === undefined) return defaultMallet();
42 if (!isObject(raw)) { issues.push({ path, message: "expected a mapping with a 'kind' key" }); return defaultMallet(); }
43 const kind = raw.kind;
44 if (kind === "stick") return fillSection(defaultStick(), raw, path, issues);
45 if (kind === "mallet") return fillSection(defaultMallet(), raw, path, issues);
46 issues.push({ path: `${path}.kind`, message: `unknown exciter kind ${JSON.stringify(kind)}; expected 'stick' or 'mallet'` });
47 return defaultMallet();
48}
50function fillHit(raw: unknown, path: string, issues: Issue[]): Hit {
51 const hit = defaultHit();
52 if (!isObject(raw)) { issues.push({ path, message: "expected a mapping" }); return hit; }
53 const { exciter, ...rest } = raw;
54 const flat: Record<string, unknown> = { time: hit.time, r: hit.r, phi: hit.phi, velocity: hit.velocity };
55 fillSection(flat, rest, path, issues);
56 return { time: flat.time as number, r: flat.r as number, phi: flat.phi as number, velocity: flat.velocity as number,
57 exciter: fillExciter(exciter, `${path}.exciter`, issues) };
58}
60/** Build a complete configuration from raw (parsed YAML) data; missing fields take the defaults. */
61export function normalizeConfig(raw: unknown): { config: SimulationConfig; issues: Issue[] } {
62 const issues: Issue[] = [];
63 const cfg = defaultConfig();
64 if (raw === null || raw === undefined) return { config: cfg, issues };
65 if (!isObject(raw)) { issues.push({ path: "", message: "the document must be a mapping" }); return { config: cfg, issues }; }
66 for (const key of Object.keys(raw)) if (!(SECTIONS as readonly string[]).includes(key)) issues.push({ path: key, message: "unknown section" });
67 fillSection(cfg.material, raw.material, "material", issues);
68 fillSection(cfg.profile, raw.profile, "profile", issues);
69 fillSection(cfg.imperfections, raw.imperfections, "imperfections", issues);
70 fillSection(cfg.mesh, raw.mesh, "mesh", issues);
71 fillSection(cfg.listener, raw.listener, "listener", issues);
72 fillSection(cfg.numerics, raw.numerics, "numerics", issues);
73 fillSection(cfg.output, raw.output, "output", issues);
74 if (raw.hits !== undefined) {
75 if (!Array.isArray(raw.hits)) issues.push({ path: "hits", message: "expected a list of hits" });
76 else cfg.hits = raw.hits.map((h, i) => fillHit(h, `hits[${i}]`, issues));
77 }
78 return { config: cfg, issues };
79}
81/** The validation rules of cymsim/config.py, plus a few that the web app needs. */
82export function validateConfig(cfg: SimulationConfig): Issue[] {
83 const issues: Issue[] = [], p = cfg.profile, n = cfg.numerics;
84 if (cfg.hits.length === 0) issues.push({ path: "hits", message: "at least one hit is required" });
85 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)" });
86 if (!(n.cfl_safety > 0 && n.cfl_safety <= 1)) issues.push({ path: "numerics.cfl_safety", message: "must lie in (0, 1]" });
87 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" });
88 if (!(n.duration > 0)) issues.push({ path: "numerics.duration", message: "must be positive" });
89 if (!(n.fs >= 8000 && n.fs <= 192000)) issues.push({ path: "numerics.fs", message: "must lie between 8000 and 192000 Hz" });
90 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" });
91 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" });
92 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)" });
93 cfg.hits.forEach((h, i) => {
94 if (h.time < 0) issues.push({ path: `hits[${i}].time`, message: "must be non-negative" });
95 if (!(h.velocity > 0)) issues.push({ path: `hits[${i}].velocity`, message: "must be positive (the exciter approaches from above)" });
96 const ex = h.exciter;
97 const extent = ex.kind === "stick" ? ex.patch_length / 2 : ex.radius;
98 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})` });
99 if (h.r < 0) issues.push({ path: `hits[${i}].r`, message: "must be non-negative" });
100 if (!(ex.mass > 0)) issues.push({ path: `hits[${i}].exciter.mass`, message: "must be positive" });
101 if (ex.kind === "stick" && !(ex.patch_points >= 1)) issues.push({ path: `hits[${i}].exciter.patch_points`, message: "must be at least 1" });
102 if (ex.kind === "mallet" && !(ex.compression > 0)) issues.push({ path: `hits[${i}].exciter.compression`, message: "must be positive" });
103 });
104 return issues;
105}
107/** Parse a YAML document into a configuration. ``config`` is null only when the YAML itself is malformed. */
108export function parseConfigYaml(text: string): { config: SimulationConfig | null; issues: Issue[] } {
109 let raw: unknown;
110 try { raw = yaml.load(text); } catch (err) { return { config: null, issues: [{ path: "", message: `YAML: ${(err as Error).message}` }] }; }
111 const { config, issues } = normalizeConfig(raw);
112 return { config, issues: issues.concat(validateConfig(config)) };
113}
115export function configToYaml(cfg: SimulationConfig): string {
116 return yaml.dump(cfg, { noRefs: true, lineWidth: -1, sortKeys: false });
117}
119export function cloneConfig(cfg: SimulationConfig): SimulationConfig { return structuredClone(cfg); }
121/** The validation relevant here, where the hits of a configuration are ignored: every rule of
122 * validateConfig except the ones about hits. */
123export function designIssues(cfg: SimulationConfig): Issue[] {
124 return validateConfig(cfg).filter((i) => i.path !== "hits" && !i.path.startsWith("hits["));
125}