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

173
web/worker/device-io.mjs Normal file
View File

@@ -0,0 +1,173 @@
import { fnv1a64 } from "./replay.mjs";
export const DEVICE_IO_SCHEMA = "sim_device_io.v1";
const MAX_ENCODERS = 8;
const MAX_ACKS = 64;
const clone = (value) => structuredClone(value);
const finite = (value) => typeof value === "number" && Number.isFinite(value);
function encoder() {
return { registered: true, speed_rps: 0, ppr: 1000, scale: 1, raw_count: 0, count: 0, position: 0, velocity: 0, phase_a: 1, phase_b: 0, phase_z: 1, index_enable: false, index_pulse: false, tick: 0, phase: 0 };
}
export class DeviceIoRuntime {
constructor(options = {}) { this.reset(options); }
reset({ encoder_count = 1, cycle_time = 0.1 } = {}) {
if (!Number.isSafeInteger(encoder_count) || encoder_count < 1 || encoder_count > MAX_ENCODERS || !finite(cycle_time) || cycle_time <= 0 || cycle_time > 10) throw new Error("device_io_config");
this.state = {
schema: DEVICE_IO_SCHEMA,
tick: 0,
cycle_time,
encoders: Array.from({ length: encoder_count }, encoder),
coolant: { mist_requested: false, flood_requested: false, lube_requested: false, mist: false, flood: false, lube: false, fault: false },
safety: { door_open: false, input_ok: true, estop: false, fault: false, interlock_ok: true, latched_fault: false, recovery_epoch: 0 },
acks: [],
last_error: "device_io_ok",
};
return this.export();
}
export() { const unsigned = clone(this.state); delete unsigned.state_hash; return { ...unsigned, state_hash: fnv1a64(unsigned) }; }
hash() { return this.export().state_hash; }
ok(command = "DEVICE_IO") { this.state.last_error = "device_io_ok"; return { status: "verified", command, state: this.export() }; }
fail(code, detail = null) { this.state.last_error = code; return { status: "blocked", code, ...(detail ? { detail } : {}), state: this.export() }; }
updateSafety() {
const safety = this.state.safety;
safety.fault = Boolean(safety.door_open || !safety.input_ok);
if (safety.fault || safety.estop) safety.latched_fault = true;
safety.interlock_ok = !safety.fault && !safety.estop && !safety.latched_fault;
}
updateCoolant() {
const coolant = this.state.coolant;
const allowed = this.state.safety.interlock_ok && !coolant.fault;
coolant.mist = allowed && coolant.mist_requested;
coolant.flood = allowed && coolant.flood_requested;
coolant.lube = allowed && coolant.lube_requested;
}
configure(options = {}) {
try { this.reset(options); return this.ok("DEVICE_CONFIG"); } catch (error) { return this.fail(error.message); }
}
encoderSet(channel, { speed_rps, ppr, scale, index_enable, reset = false } = {}) {
const item = this.state.encoders[channel];
if (!item || !finite(speed_rps ?? item.speed_rps) || Math.abs(speed_rps ?? item.speed_rps) > 1000000) return this.fail("device_encoder_speed");
if (ppr !== undefined && (!Number.isSafeInteger(ppr) || ppr < 1 || ppr > 100000000)) return this.fail("device_encoder_ppr");
if (scale !== undefined && (!finite(scale) || scale <= 0)) return this.fail("device_encoder_scale");
if (speed_rps !== undefined) item.speed_rps = speed_rps;
if (ppr !== undefined) item.ppr = ppr;
if (scale !== undefined) item.scale = scale;
if (index_enable !== undefined) { if (typeof index_enable !== "boolean") return this.fail("device_encoder_index"); item.index_enable = index_enable; }
if (reset === true) { item.raw_count = 0; item.count = 0; item.position = 0; item.phase = 0; item.index_pulse = false; }
return this.ok("ENCODER_SET");
}
coolantCommand({ mist = false, flood = false, lube = false } = {}) {
if (![mist, flood, lube].every((value) => typeof value === "boolean")) return this.fail("device_coolant_argument");
if ((mist || flood || lube) && !this.state.safety.interlock_ok) return this.fail("device_interlock");
Object.assign(this.state.coolant, { mist_requested: mist, flood_requested: flood, lube_requested: lube });
this.updateCoolant();
return this.ok("COOLANT_COMMAND");
}
coolantFault(fault) {
if (typeof fault !== "boolean") return this.fail("device_coolant_fault");
this.state.coolant.fault = fault; this.updateCoolant();
return fault ? this.fail("device_coolant_fault") : this.ok("COOLANT_FAULT_CLEAR");
}
doorSet(open) {
if (typeof open !== "boolean") return this.fail("device_door_argument");
this.state.safety.door_open = open; this.updateSafety(); this.updateCoolant();
return open ? this.fail("device_interlock") : this.ok("DOOR_SET");
}
interlockSet(inputOk) {
if (typeof inputOk !== "boolean") return this.fail("device_interlock_argument");
this.state.safety.input_ok = inputOk; this.updateSafety(); this.updateCoolant();
return inputOk ? this.ok("INTERLOCK_SET") : this.fail("device_interlock");
}
interlockReset() {
this.updateSafety();
if (this.state.safety.door_open || !this.state.safety.input_ok || this.state.safety.estop) return this.fail("device_interlock");
this.state.safety.latched_fault = false; this.state.safety.recovery_epoch += 1;
this.updateSafety(); this.updateCoolant();
return this.ok("INTERLOCK_RESET");
}
estop(active) {
if (typeof active !== "boolean") return this.fail("device_estop_argument");
this.state.safety.estop = active; this.updateSafety(); this.updateCoolant();
return active ? this.fail("device_interlock") : this.ok("DEVICE_ESTOP");
}
issueAck(operation_id, timeout_ticks = 10) {
if (typeof operation_id !== "string" || operation_id.length === 0 || !Number.isSafeInteger(timeout_ticks) || timeout_ticks < 1 || timeout_ticks > 1000000) return this.fail("device_ack_argument");
const existing = this.state.acks.find((entry) => entry.operation_id === operation_id);
if (existing) return this.fail("device_ack_duplicate", { command: clone(existing) });
if (this.state.acks.length >= MAX_ACKS) return this.fail("device_ack_capacity");
const command = { operation_id, issued_tick: this.state.tick, deadline_tick: this.state.tick + timeout_ticks, status: "pending" };
this.state.acks.push(command);
return this.ok("DEVICE_ACK_ISSUE");
}
ack(operation_id) {
const command = this.state.acks.find((entry) => entry.operation_id === operation_id);
if (!command) return this.fail("device_ack_missing");
if (command.status !== "pending") return this.fail(command.status === "timed_out" ? "device_ack_late" : "device_ack_state", { command: clone(command) });
command.status = "acked";
return this.ok("DEVICE_ACK");
}
abortAck(operation_id) {
const command = this.state.acks.find((entry) => entry.operation_id === operation_id);
if (!command) return this.fail("device_ack_missing");
if (command.status === "acked") return this.fail("device_ack_too_late", { command: clone(command) });
command.status = "aborted";
return this.ok("DEVICE_ACK_ABORT");
}
advanceAck(tick) {
for (const command of this.state.acks) if (command.status === "pending" && tick >= command.deadline_tick) command.status = "timed_out";
}
ackStep(tick) {
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("device_tick_regression");
this.state.tick = tick; this.advanceAck(tick); return this.ok("DEVICE_ACK_TICK");
}
step(tick) {
if (!Number.isSafeInteger(tick) || tick < this.state.tick) return this.fail("device_tick_regression");
const before = clone(this.state);
try {
for (let current = this.state.tick + 1; current <= tick; current += 1) {
this.state.tick = current; this.updateSafety(); this.updateCoolant();
for (const item of this.state.encoders) {
const delta = item.speed_rps * item.ppr * 4 * this.state.cycle_time;
item.phase += delta;
if (!finite(item.phase) || Math.abs(item.phase) > 2147483647) throw new Error("device_encoder_range");
item.raw_count = item.phase < 0 ? Math.ceil(item.phase) : Math.floor(item.phase);
item.count = item.raw_count; item.position = item.raw_count / item.scale; item.velocity = item.speed_rps * item.ppr * 4 / item.scale;
const phase = ((item.raw_count % 4) + 4) % 4;
item.phase_a = phase === 0 || phase === 1; item.phase_b = phase === 1 || phase === 2; item.index_pulse = false;
if (item.index_enable && item.raw_count % (item.ppr * 4) === 0) { item.phase_z = 1; item.index_pulse = true; item.index_enable = false; } else item.phase_z = 0;
item.tick = current;
}
this.advanceAck(current);
}
return this.ok("DEVICE_STEP");
} catch (error) { this.state = before; return this.fail(error.message); }
}
restore(value) {
if (!value || value.schema !== DEVICE_IO_SCHEMA) throw new Error("device_io_restore_schema");
const unsigned = clone(value); delete unsigned.state_hash;
if (value.state_hash !== fnv1a64(unsigned)) throw new Error("device_io_restore_hash");
if (!Array.isArray(unsigned.encoders) || unsigned.encoders.length > MAX_ENCODERS || !Array.isArray(unsigned.acks) || unsigned.acks.length > MAX_ACKS) throw new Error("device_io_restore_capacity");
this.state = unsigned; return this.export();
}
}