// YAML <-> SimulationConfig: parsing with defaults for omitted fields, reporting of unknown keys // and wrong types, the validation rules of the Python package, and serialization in the same // field order the Python package writes. import yaml from "js-yaml"; import { defaultConfig, defaultHit, defaultMallet, defaultStick, type Exciter, type Hit, type SimulationConfig } from "./schema.ts"; export interface Issue { path: string; message: string } const SECTIONS = ["material", "profile", "imperfections", "mesh", "hits", "listener", "numerics", "output"] as const; function isObject(x: unknown): x is Record { return typeof x === "object" && x !== null && !Array.isArray(x); } /** Fill one flat section from raw data, reporting unknown keys and wrong types. */ function fillSection(target: T, raw: unknown, path: string, issues: Issue[]): T { if (raw === undefined || raw === null) return target; if (!isObject(raw)) { issues.push({ path, message: "expected a mapping" }); return target; } const t = target as unknown as Record; for (const key of Object.keys(raw)) { if (!(key in t)) { issues.push({ path: `${path}.${key}`, message: "unknown field" }); continue; } const def = t[key], val = raw[key]; if (typeof def === "number") { if (typeof val === "number" && Number.isFinite(val)) t[key] = val; else issues.push({ path: `${path}.${key}`, message: "expected a number" }); } else if (typeof def === "boolean") { if (typeof val === "boolean") t[key] = val; else issues.push({ path: `${path}.${key}`, message: "expected true or false" }); } else if (typeof def === "string") { if (typeof val === "string") t[key] = val; else issues.push({ path: `${path}.${key}`, message: "expected a string" }); } else if (key === "probes") { const isPoint = (x: unknown) => Array.isArray(x) && x.length === 2 && x.every((c) => typeof c === "number" && Number.isFinite(c)); if (Array.isArray(val) && val.every(isPoint)) t[key] = val.map((x) => (x as number[]).slice()); else issues.push({ path: `${path}.${key}`, message: "expected a list of points [x, y]" }); } else if (Array.isArray(def)) { if (Array.isArray(val) && val.every((x) => typeof x === "number" && Number.isFinite(x))) { if (key === "position" && val.length !== 3) issues.push({ path: `${path}.${key}`, message: "expected three numbers" }); else t[key] = val.slice(); } else issues.push({ path: `${path}.${key}`, message: "expected a list of numbers" }); } } return target; } function fillExciter(raw: unknown, path: string, issues: Issue[]): Exciter { if (raw === undefined) return defaultMallet(); if (!isObject(raw)) { issues.push({ path, message: "expected a mapping with a 'kind' key" }); return defaultMallet(); } const kind = raw.kind; if (kind === "stick") return fillSection(defaultStick(), raw, path, issues); if (kind === "mallet") return fillSection(defaultMallet(), raw, path, issues); issues.push({ path: `${path}.kind`, message: `unknown exciter kind ${JSON.stringify(kind)}; expected 'stick' or 'mallet'` }); return defaultMallet(); } function fillHit(raw: unknown, path: string, issues: Issue[]): Hit { const hit = defaultHit(); if (!isObject(raw)) { issues.push({ path, message: "expected a mapping" }); return hit; } const { exciter, ...rest } = raw; const flat: Record = { time: hit.time, r: hit.r, phi: hit.phi, velocity: hit.velocity }; fillSection(flat, rest, path, issues); return { time: flat.time as number, r: flat.r as number, phi: flat.phi as number, velocity: flat.velocity as number, exciter: fillExciter(exciter, `${path}.exciter`, issues) }; } /** Build a complete configuration from raw (parsed YAML) data; missing fields take the defaults. */ export function normalizeConfig(raw: unknown): { config: SimulationConfig; issues: Issue[] } { const issues: Issue[] = []; const cfg = defaultConfig(); if (raw === null || raw === undefined) return { config: cfg, issues }; if (!isObject(raw)) { issues.push({ path: "", message: "the document must be a mapping" }); return { config: cfg, issues }; } for (const key of Object.keys(raw)) if (!(SECTIONS as readonly string[]).includes(key)) issues.push({ path: key, message: "unknown section" }); fillSection(cfg.material, raw.material, "material", issues); fillSection(cfg.profile, raw.profile, "profile", issues); fillSection(cfg.imperfections, raw.imperfections, "imperfections", issues); fillSection(cfg.mesh, raw.mesh, "mesh", issues); fillSection(cfg.listener, raw.listener, "listener", issues); fillSection(cfg.numerics, raw.numerics, "numerics", issues); fillSection(cfg.output, raw.output, "output", issues); if (raw.hits !== undefined) { if (!Array.isArray(raw.hits)) issues.push({ path: "hits", message: "expected a list of hits" }); else cfg.hits = raw.hits.map((h, i) => fillHit(h, `hits[${i}]`, issues)); } return { config: cfg, issues }; } /** The validation rules of cymsim/config.py, plus a few that the web app needs. */ export function validateConfig(cfg: SimulationConfig): Issue[] { const issues: Issue[] = [], p = cfg.profile, n = cfg.numerics; if (cfg.hits.length === 0) issues.push({ path: "hits", message: "at least one hit is required" }); 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)" }); if (n.stability_bound !== "global" && n.stability_bound !== "element") issues.push({ path: "numerics.stability_bound", message: "must be 'global' or 'element'" }); if (!(n.cfl_safety > 0 && n.cfl_safety <= 1)) issues.push({ path: "numerics.cfl_safety", message: "must lie in (0, 1]" }); 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" }); if (!(n.duration > 0)) issues.push({ path: "numerics.duration", message: "must be positive" }); if (!(n.fs >= 8000 && n.fs <= 192000)) issues.push({ path: "numerics.fs", message: "must lie between 8000 and 192000 Hz" }); 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)" }); if (!(cfg.output.displacement_rate >= 0)) issues.push({ path: "output.displacement_rate", message: "must be non-negative" }); 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" }); 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" }); 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)" }); cfg.hits.forEach((h, i) => { if (h.time < 0) issues.push({ path: `hits[${i}].time`, message: "must be non-negative" }); if (!(h.velocity > 0)) issues.push({ path: `hits[${i}].velocity`, message: "must be positive (the exciter approaches from above)" }); const ex = h.exciter; const extent = ex.kind === "stick" ? ex.patch_length / 2 : ex.radius; 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})` }); if (h.r < 0) issues.push({ path: `hits[${i}].r`, message: "must be non-negative" }); if (!(ex.mass > 0)) issues.push({ path: `hits[${i}].exciter.mass`, message: "must be positive" }); if (ex.kind === "stick" && !(ex.patch_points >= 1)) issues.push({ path: `hits[${i}].exciter.patch_points`, message: "must be at least 1" }); if (ex.kind === "mallet" && !(ex.compression > 0)) issues.push({ path: `hits[${i}].exciter.compression`, message: "must be positive" }); }); return issues; } /** Parse a YAML document into a configuration. ``config`` is null only when the YAML itself is malformed. */ export function parseConfigYaml(text: string): { config: SimulationConfig | null; issues: Issue[] } { let raw: unknown; try { raw = yaml.load(text); } catch (err) { return { config: null, issues: [{ path: "", message: `YAML: ${(err as Error).message}` }] }; } const { config, issues } = normalizeConfig(raw); return { config, issues: issues.concat(validateConfig(config)) }; } export function configToYaml(cfg: SimulationConfig): string { return yaml.dump(cfg, { noRefs: true, lineWidth: -1, sortKeys: false }); } export function cloneConfig(cfg: SimulationConfig): SimulationConfig { return structuredClone(cfg); }