import { centroid, onPlate, type ProblemSpec } from "./types"; export interface ValidationIssue { /** Dotted path into the spec, e.g. "plate.h" */ path: string; message: string; severity: "error" | "warning"; } /** * Structural checks a control panel can show before running anything. The * `.m` code repeats the checks that matter for the numerics, so a spec that * passes here can still be rejected by the solver with a legible message. */ export function validateSpec(spec: ProblemSpec): ValidationIssue[] { const out: ValidationIssue[] = []; const err = (path: string, message: string) => out.push({ path, message, severity: "error" }); const warn = (path: string, message: string) => out.push({ path, message, severity: "warning" }); const { plate, material, excitation, probe, output, fd, damping, mount, gravity, listener } = spec; if (!(plate.Lx > 0)) err("plate.Lx", "must be positive"); if (!(plate.Ly > 0)) err("plate.Ly", "must be positive"); if (!(plate.h > 0)) err("plate.h", "must be positive"); if (plate.h > 0.1 * Math.min(plate.Lx, plate.Ly)) warn("plate.h", "thickness above a tenth of the shorter side: thin-plate theory is unreliable"); if (!(material.E > 0)) err("material.E", "must be positive"); if (!(material.nu >= 0 && material.nu < 0.5)) err("material.nu", "must lie in [0, 0.5)"); if (!(material.rho > 0)) err("material.rho", "must be positive"); if (damping.sigma0 < 0 || damping.sigma1 < 0) err("damping", "loss rates cannot be negative"); if (!(gravity.g >= 0)) err("gravity.g", "cannot be negative"); const where = plate.shape === "ellipse" ? "outside the ellipse" : "outside the plate"; if (!onPlate(plate, excitation.x, excitation.y)) err("excitation", `strike point lies ${where}`); if (!onPlate(plate, probe.x, probe.y)) err("probe", `probe lies ${where}`); if (!(excitation.radius > 0)) err("excitation.radius", "footprint radius must be positive"); if (excitation.type === "impulse" && !(excitation.duration > 0)) err("excitation.duration", "contact time must be positive"); if (excitation.type === "release") { if (!(excitation.displacement > 0)) err("excitation.displacement", "pull must be positive"); if (excitation.displacement > 0.05 * Math.min(plate.Lx, plate.Ly)) warn( "excitation.displacement", "a pull beyond a twentieth of the plate's size is outside small-angle theory", ); } const cm = centroid(plate); const near = (x: number, y: number, r: number) => Math.hypot(x - cm.x, y - cm.y) < r; switch (mount.type) { case "thread": if (!onPlate(plate, mount.x, mount.y)) err("mount", `the hole lies ${where}`); else if (near(mount.x, mount.y, 0.05 * Math.min(plate.Lx, plate.Ly))) err( "mount", "the hole is at the centre of mass; the plate would hang in no particular orientation", ); if (!(mount.length > 0)) err("mount.length", "thread length must be positive"); if (!(mount.thread.diameter > 0) || !(mount.thread.G > 0)) err("mount.thread", "thread diameter and shear modulus must be positive"); break; case "threads": { if (!onPlate(plate, mount.x1, mount.y1)) err("mount", `hole 1 lies ${where}`); if (!onPlate(plate, mount.x2, mount.y2)) err("mount", `hole 2 lies ${where}`); if (!(mount.length > 0)) err("mount.length", "thread length must be positive"); const ex = mount.x2 - mount.x1; const ey = mount.y2 - mount.y1; const s2 = Math.hypot(ex, ey); if (s2 < 1e-6) err("mount", "the two holes coincide"); else { const sc = ((cm.x - mount.x1) * ex + (cm.y - mount.y1) * ey) / (s2 * s2); if (!(sc > 0 && sc < 1)) err("mount", "the centre of mass is not between the holes: one thread would go slack"); } break; } case "pads": if (mount.pads.length === 0) err("mount", "need at least one pad"); mount.pads.forEach((p, k) => { if (!onPlate(plate, p.x, p.y)) err("mount", `pad ${k + 1} is centred ${where}`); if (!(p.rx > 0) || !(p.ry > 0)) err("mount", `pad ${k + 1} needs positive half-widths`); }); if (!(mount.k > 0)) err("mount.k", "pad stiffness must be positive"); if (mount.c < 0) err("mount.c", "pad damping cannot be negative"); if (mount.pads.length < 2) warn( "mount", "a plate on one pad tips unless the pad is wide; the static solve will say if it does", ); break; case "cymbal": if (!(mount.r_felt > mount.r_rod) || !(mount.r_rod >= 0)) err("mount", "the felt's outer radius must exceed the rod's"); if (mount.r_felt > 0.5 * Math.min(plate.Lx, plate.Ly)) err("mount", "the felt is larger than the plate"); if (!(mount.k > 0)) err("mount.k", "felt stiffness must be positive"); if (mount.c < 0) err("mount.c", "felt damping cannot be negative"); if (mount.preload < 0) err("mount.preload", "preload cannot be negative"); break; case "vise": { const side = mount.edge.startsWith("x") ? plate.Lx : plate.Ly; if (!(mount.depth > 0)) err("mount.depth", "jaw depth must be positive"); if (mount.depth > 0.5 * side) err("mount.depth", "the jaws hold more than half the plate"); if (!(mount.tilt >= 0 && mount.tilt <= 180)) err("mount.tilt", "tilt runs from 0 to 180 degrees"); break; } case "pinched": if (!onPlate(plate, mount.x, mount.y)) err("mount", `the pinch point lies ${where}`); if (!(mount.radius > 0)) err("mount.radius", "patch radius must be positive"); if (!(mount.k > 0)) err("mount.k", "grip stiffness must be positive"); if (mount.c < 0) err("mount.c", "grip damping cannot be negative"); if (!(mount.tilt >= 0 && mount.tilt <= 90)) err("mount.tilt", "tilt runs from 0 to 90 degrees"); break; } if (!(listener.z !== 0)) err("listener.z", "the listener cannot be in the plane of the plate"); if (!(output.fs > 0)) err("output.fs", "sample rate must be positive"); if (output.nframes < 2) err("output.nframes", "need at least 2 frames"); if (fd.nx < 5 || fd.ny < 5) err("fd", "finite-difference grid needs at least 5 nodes per side"); if (!(fd.duration > 0)) err("fd.duration", "duration must be positive"); if (fd.cfl > 1) warn("fd.cfl", "above the stability limit: the run will blow up"); const dxFd = plate.Lx / (fd.nx - 1); const dyFd = plate.Ly / (fd.ny - 1); if (excitation.radius < Math.max(dxFd, dyFd)) warn("excitation.radius", "footprint smaller than a grid cell: the load lands on one node"); return out; }