Implement AXIS parity UI and execution baseline

This commit is contained in:
2026-08-23 07:28:37 +08:00
parent 1325504544
commit 06f96f85e5
463 changed files with 84893 additions and 692 deletions

266
web/worker/hal-runtime.mjs Normal file
View File

@@ -0,0 +1,266 @@
import { fnv1a64 } from "./replay.mjs";
export const HAL_RUNTIME_SCHEMA = "sim_hal_runtime.v1";
const MAX_PIN_COUNT = 256;
const MAX_PARAM_COUNT = 256;
const MAX_COMPONENT_COUNT = 64;
const MAX_THREAD_COUNT = 16;
const MAX_PERIOD_TICKS = 1000000;
const TYPE_NAMES = new Set(["bit", "s32", "u32", "float"]);
const NAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,46}$/;
const SPEC = Object.freeze({
corexy_by_hal: { pins: { "j0-motor-pos-cmd": "float", "j1-motor-pos-cmd": "float", "alpha-cmd": "float", "beta-cmd": "float", "alpha-fb": "float", "beta-fb": "float", "j0-motor-pos-fb": "float", "j1-motor-pos-fb": "float" }, params: {} },
constant: { pins: { out: "float" }, params: { value: ["float", 0] } },
and2: { pins: { in0: "bit", in1: "bit", out: "bit" }, params: {} },
or2: { pins: { in0: "bit", in1: "bit", out: "bit" }, params: {} },
xor2: { pins: { in0: "bit", in1: "bit", out: "bit" }, params: {} },
not: { pins: { in: "bit", out: "bit" }, params: {} },
abs: { pins: { in: "float", out: "float", sign: "bit", "is-positive": "bit", "is-negative": "bit" }, params: {} },
abs_s32: { pins: { in: "s32", out: "s32", sign: "bit", "is-positive": "bit", "is-negative": "bit" }, params: {} },
lowpass: { pins: { in: "float", out: "float", load: "bit" }, params: { gain: ["float", 0] } },
ddt: { pins: { in: "float", out: "float" }, params: {} },
integ: { pins: { in: "float", gain: "float", out: "float", reset: "bit", max: "float", min: "float" }, params: {} },
mux2: { pins: { sel: "bit", out: "float", in1: "float", in0: "float" }, params: {} },
sum2: { pins: { in0: "float", in1: "float", out: "float" }, params: { gain0: ["float", 1], gain1: ["float", 1], offset: ["float", 0] } },
mult2: { pins: { in0: "float", in1: "float", out: "float" }, params: {} },
div2: { pins: { in0: "float", in1: "float", out: "float" }, params: { deadband: ["float", 0] } },
hypot: { pins: { in0: "float", in1: "float", in2: "float", out: "float" }, params: {} },
near: { pins: { in1: "float", in2: "float", out: "bit" }, params: { scale: ["float", 1], difference: ["float", 0] } },
comp: { pins: { in0: "float", in1: "float", out: "bit", equal: "bit" }, params: { hyst: ["float", 0] } },
invert: { pins: { in: "float", out: "float" }, params: { deadband: ["float", 0] } },
edge: { pins: { in: "bit", out: "bit", "out-invert": "bit" }, params: { both: ["bit", 0], "in-edge": ["bit", 1], "out-width-ns": ["s32", 0], "time-left-ns": ["s32", 0], "last-in": ["bit", 0] } },
toggle: { pins: { in: "bit", out: "bit" }, params: { debounce: ["u32", 2] } },
estop_latch: { pins: { "ok-in": "bit", "fault-in": "bit", reset: "bit", "ok-out": "bit", "fault-out": "bit", watchdog: "bit" }, params: {} },
});
const clone = (value) => structuredClone(value);
const validName = (value) => typeof value === "string" && NAME_RE.test(value);
const finite = (value) => typeof value === "number" && Number.isFinite(value);
const normalizeType = (value) => value === "signed" ? "s32" : value === "unsigned" ? "u32" : value;
function valueFor(type, value = 0) {
if (!TYPE_NAMES.has(type) || (type === "bit" && ![0, 1, false, true].includes(value)) || !finite(type === "bit" ? Number(value) : value)) throw new Error("hal_value_invalid");
if (type === "bit") return value ? 1 : 0;
if (type === "s32") {
if (!Number.isSafeInteger(value) || value < -2147483648 || value > 2147483647) throw new Error("hal_s32_range");
return value;
}
if (type === "u32") {
if (!Number.isSafeInteger(value) || value < 0 || value > 4294967295) throw new Error("hal_u32_range");
return value;
}
return Number(value);
}
function qualified(instance, suffix) {
if (!validName(instance) || !validName(suffix) || instance.length + suffix.length + 1 > 47) throw new Error("hal_name_invalid");
return `${instance}.${suffix}`;
}
export function parseCompSource(source) {
if (typeof source !== "string" || source.length === 0 || source.length > 1024 * 1024) return { status: "blocked", code: "hal_comp_source_invalid" };
const lines = source.split(/\r?\n/);
let component = null;
const pins = [];
const params = [];
const functions = [];
const options = [];
const errors = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index].replace(/\/\/.*$/, "").trim();
let match = /^component\s+([A-Za-z_][A-Za-z0-9_]*)\b/.exec(line);
if (match) { if (component && component !== match[1]) errors.push(`duplicate_component:${index + 1}`); component = match[1]; continue; }
match = /^pin\s+(in|out|io)\s+([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_.#-]*)/.exec(line);
if (match) { const type = normalizeType(match[2]); if (!TYPE_NAMES.has(type)) errors.push(`hal_type_unsupported:${index + 1}`); pins.push({ direction: match[1], type, name: match[3], line: index + 1 }); continue; }
match = /^param\s+(r|rw)\s+([A-Za-z_][A-Za-z0-9_]*)\s+([A-Za-z_][A-Za-z0-9_.-]*)(?:\s*=\s*([^\s]+))?/.exec(line);
if (match) { const type = normalizeType(match[2]); if (!TYPE_NAMES.has(type)) errors.push(`hal_type_unsupported:${index + 1}`); params.push({ mode: match[1], type, name: match[3], default: match[4] ?? null, line: index + 1 }); continue; }
match = /^function\s+([^;]+);/.exec(line);
if (match) functions.push(...match[1].split(/\s+/).filter(Boolean).map((name) => ({ name, line: index + 1 })));
match = /^option\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*=\s*([^\s]+))?/.exec(line);
if (match) options.push({ name: match[1], value: match[2] ?? null, line: index + 1 });
}
if (!component) errors.push("hal_component_missing");
const names = [...pins, ...params].map((item) => item.name);
if (new Set(names).size !== names.length) errors.push("hal_declaration_duplicate");
if (component && !SPEC[component]) errors.push("hal_component_unsupported");
return { schema: "hal-component-source.v1", status: errors.length ? "blocked" : "verified", ...(errors.length ? { code: errors[0].split(":", 1)[0] } : {}), component, pins, params, functions, options, errors };
}
export class HalRuntime {
constructor() { this.reset(); }
reset() {
this.state = { schema: HAL_RUNTIME_SCHEMA, tick: 0, threads: {}, pins: {}, params: {}, components: [], links: [], last_error: null };
return this.export();
}
export() { const unsigned = clone(this.state); return { ...unsigned, state_hash: fnv1a64(unsigned) }; }
hash() { return this.export().state_hash; }
fail(code, detail = null) { this.state.last_error = code; return { status: "error", code, ...(detail ? { detail } : {}), state: this.export() }; }
ok(command) { this.state.last_error = null; return { status: "verified", command, state: this.export() }; }
registerThread(name, period_ticks) {
if (!validName(name) || !Number.isSafeInteger(period_ticks) || period_ticks < 1 || period_ticks > MAX_PERIOD_TICKS) return this.fail("hal_thread_invalid");
if (this.state.threads[name]) return this.fail("hal_thread_duplicate");
if (Object.keys(this.state.threads).length >= MAX_THREAD_COUNT) return this.fail("hal_thread_capacity");
this.state.threads[name] = { name, period_ticks };
return this.ok("HAL_THREAD");
}
registerComponent(instance, type, thread = "servo-thread") {
if (!validName(instance) || !validName(type) || !validName(thread)) return this.fail("hal_component_name");
if (!SPEC[type]) return this.fail("hal_component_unsupported");
if (!this.state.threads[thread]) return this.fail("hal_thread_missing");
if (this.state.components.some((component) => component.instance === instance)) return this.fail("hal_component_duplicate");
if (this.state.components.length >= MAX_COMPONENT_COUNT) return this.fail("hal_component_capacity");
const spec = SPEC[type];
const before = clone(this.state);
try {
const component = { instance, type, thread, calls: 0, failed: false, memory: [0, 0, 0, 0], flags: [0, 0, 0, 0] };
for (const [suffix, pinType] of Object.entries(spec.pins)) {
const name = qualified(instance, suffix);
if (this.state.pins[name] || this.state.params[name]) throw new Error("hal_pin_duplicate");
if (Object.keys(this.state.pins).length >= MAX_PIN_COUNT) throw new Error("hal_pin_capacity");
this.state.pins[name] = { name, type: pinType, value: valueFor(pinType) };
}
for (const [suffix, [paramType, initial]] of Object.entries(spec.params)) {
const name = qualified(instance, suffix);
if (this.state.pins[name] || this.state.params[name]) throw new Error("hal_param_duplicate");
if (Object.keys(this.state.params).length >= MAX_PARAM_COUNT) throw new Error("hal_param_capacity");
this.state.params[name] = { name, type: paramType, value: valueFor(paramType, initial) };
}
if (type === "integ") {
this.state.pins[qualified(instance, "gain")].value = 1;
this.state.pins[qualified(instance, "max")].value = 1e20;
this.state.pins[qualified(instance, "min")].value = -1e20;
}
if (type === "estop_latch") {
this.state.pins[qualified(instance, "ok-in")].value = 1;
this.state.pins[qualified(instance, "fault-out")].value = 1;
}
this.state.components.push(component);
return this.ok("HAL_COMPONENT");
} catch (error) {
this.state = before;
return this.fail(error.message);
}
}
configure({ threads = [], components = [], links = [] } = {}) {
if (!Array.isArray(threads) || !Array.isArray(components) || !Array.isArray(links)) return this.fail("hal_config_invalid");
this.reset();
if (!threads.length) threads = [{ name: "servo-thread", period_ticks: 1 }];
for (const thread of threads) { const result = this.registerThread(thread?.name, thread?.period_ticks); if (result.status === "error") return result; }
for (const component of components) {
const instances = Array.isArray(component?.instances) && component.instances.length ? component.instances : [component?.instance ?? component?.name ?? component?.module];
for (const instance of instances) {
const result = this.registerComponent(instance, component?.type ?? component?.module, component?.thread ?? "servo-thread");
if (result.status === "error") return result;
}
}
for (const link of links) { const result = this.connect(link?.source, link?.target); if (result.status === "error") return result; }
return this.ok("HAL_CONFIG");
}
setValue(collection, name, value, type = undefined) {
const entry = this.state[collection][name];
if (!entry) return this.fail("hal_value_missing");
const normalized = value && typeof value === "object" && !Array.isArray(value) ? value.value : value;
const requestedType = value && typeof value === "object" && !Array.isArray(value) ? value.type : type;
if (requestedType !== undefined && normalizeType(requestedType) !== entry.type) return this.fail("hal_value_type");
try { entry.value = valueFor(entry.type, normalized); } catch (error) { return this.fail(error.message); }
return this.ok(collection === "pins" ? "HAL_SET_PIN" : "HAL_SET_PARAM");
}
setPin(name, value, type = undefined) { return this.setValue("pins", name, value, type); }
setParam(name, value, type = undefined) { return this.setValue("params", name, value, type); }
getPin(name) { return this.state.pins[name] ? clone(this.state.pins[name]) : null; }
getParam(name) { return this.state.params[name] ? clone(this.state.params[name]) : null; }
connect(source, target) {
if (!this.state.pins[source] || !this.state.pins[target]) return this.fail("hal_connection_missing");
if (this.state.pins[source].type !== this.state.pins[target].type) return this.fail("hal_connection_type");
if (this.state.links.some((link) => link.source === source && link.target === target)) return this.fail("hal_connection_duplicate");
this.state.links.push({ source, target });
this.state.pins[target].value = this.state.pins[source].value;
return this.ok("HAL_CONNECT");
}
setFailure(instance, failed = true) {
const component = this.state.components.find((item) => item.instance === instance);
if (!component) return this.fail("hal_component_missing");
component.failed = Boolean(failed);
return this.ok("HAL_FAILURE");
}
readPin(component, suffix) { return this.state.pins[qualified(component.instance, suffix)].value; }
writePin(component, suffix, value) { const pin = this.state.pins[qualified(component.instance, suffix)]; pin.value = valueFor(pin.type, value); }
readParam(component, suffix) { return this.state.params[qualified(component.instance, suffix)].value; }
writeParam(component, suffix, value) { const param = this.state.params[qualified(component.instance, suffix)]; param.value = valueFor(param.type, value); }
runComponent(component) {
const p = (suffix) => this.readPin(component, suffix);
const w = (suffix, value) => this.writePin(component, suffix, value);
const q = (suffix) => this.readParam(component, suffix);
switch (component.type) {
case "corexy_by_hal": w("j0-motor-pos-fb", p("alpha-fb") + p("beta-fb")); w("j1-motor-pos-fb", p("alpha-fb") - p("beta-fb")); w("alpha-cmd", 0.5 * (p("j0-motor-pos-cmd") + p("j1-motor-pos-cmd"))); w("beta-cmd", 0.5 * (p("j0-motor-pos-cmd") - p("j1-motor-pos-cmd"))); break;
case "constant": w("out", q("value")); break;
case "and2": w("out", p("in0") && p("in1")); break;
case "or2": w("out", p("in0") || p("in1")); break;
case "xor2": w("out", Boolean(p("in0") !== p("in1"))); break;
case "not": w("out", !p("in")); break;
case "abs": { const value = p("in"); w("out", Math.abs(value)); w("sign", value < 0); w("is-positive", value > 0.000001); w("is-negative", value < -0.000001); break; }
case "abs_s32": { const value = p("in"); if (value === -2147483648) throw new Error("hal_s32_range"); w("out", Math.abs(value)); w("sign", value < 0); w("is-positive", value > 0); w("is-negative", value < 0); break; }
case "lowpass": { const input = p("in"); const output = p("out"); w("out", p("load") ? input : output + (input - output) * q("gain")); break; }
case "ddt": { const input = p("in"); w("out", input - component.memory[0]); component.memory[0] = input; break; }
case "integ": { const input = p("in"); component.memory[0] = p("reset") ? 0 : component.memory[0] + p("gain") * input; component.memory[0] = Math.min(p("max"), Math.max(p("min"), component.memory[0])); w("out", component.memory[0]); break; }
case "mux2": w("out", p("sel") ? p("in1") : p("in0")); break;
case "sum2": w("out", p("in0") * q("gain0") + p("in1") * q("gain1") + q("offset")); break;
case "mult2": w("out", p("in0") * p("in1")); break;
case "div2": { let divisor = p("in1"); const deadband = Math.max(1e-12, q("deadband")); if (Math.abs(divisor) < deadband) divisor = divisor < 0 ? -deadband : deadband; w("out", p("in0") / divisor); break; }
case "hypot": w("out", Math.hypot(p("in0"), p("in1"), p("in2"))); break;
case "near": { let left = p("in1"); let right = p("in2"); if (left < 0) { left = -left; right = -right; } w("out", (q("scale") > 1 && left / q("scale") <= right && right <= left * q("scale")) || Math.abs(left - right) <= q("difference")); break; }
case "comp": { const difference = p("in1") - p("in0"); const half = 0.5 * q("hyst"); if (difference < -half) { w("out", 0); w("equal", 0); } else if (difference > half) { w("out", 1); w("equal", 0); } else w("equal", 1); break; }
case "invert": { const deadband = Math.max(1e-12, q("deadband")); let input = p("in"); if (Math.abs(input) < deadband) input = input < 0 ? -deadband : deadband; w("out", 1 / input); break; }
case "edge": { const input = p("in"); const previous = q("last-in"); if (!component.flags[0]) { component.flags[0] = 1; w("out", 0); w("out-invert", 1); } else { const rise = input && !previous; const fall = !input && previous; const hit = q("both") ? rise || fall : q("in-edge") ? fall : rise; w("out", hit); w("out-invert", !hit); } this.writeParam(component, "last-in", input); break; }
case "toggle": { const input = p("in"); let output = p("out"); const debounce = q("debounce") < 1 || q("debounce") > 10000 ? 2 : q("debounce"); if (input) { component.flags[0] = Math.min(debounce, component.flags[0] + 1); if (component.flags[0] >= debounce && !component.flags[1]) { output = output ? 0 : 1; component.flags[1] = 1; } } else { component.flags[0] = 0; component.flags[1] = 0; } w("out", output); break; }
case "estop_latch": { const ok = p("ok-in"); const fault = p("fault-in"); const reset = p("reset"); if (!ok || fault) component.flags[0] = 0; else if (reset && !component.flags[1]) component.flags[0] = 1; component.flags[1] = reset ? 1 : 0; w("ok-out", component.flags[0]); w("fault-out", !component.flags[0]); if (component.flags[0]) component.flags[2] = component.flags[2] ? 0 : 1; w("watchdog", component.flags[2]); break; }
default: throw new Error("hal_component_unsupported");
}
}
step(tick) {
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("hal_tick_regression");
const before = clone(this.state);
try {
for (let current = this.state.tick + 1; current <= tick; current += 1) {
for (const link of this.state.links) this.state.pins[link.target].value = this.state.pins[link.source].value;
for (const component of this.state.components) {
const period = this.state.threads[component.thread].period_ticks;
if (current % period !== 0) continue;
if (component.failed) throw new Error("hal_function_failure");
this.runComponent(component);
component.calls += 1;
}
}
this.state.tick = tick;
return this.ok("HAL_STEP");
} catch (error) {
this.state = before;
return this.fail(error.message);
}
}
restore(value) {
if (!value || value.schema !== HAL_RUNTIME_SCHEMA) throw new Error("hal_restore_schema");
const unsigned = clone(value); delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("hal_restore_hash");
this.state = unsigned;
return this.export();
}
}
export { SPEC as STATIC_HAL_COMPONENTS };
export { HalRuntime as HalComponentRuntime, parseCompSource as parseHalComponent };