f3a8a38acd
web-platform-ci / Standalone decision service (no cloud credentials) (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
lekiwi-compatibility / cpu-compatibility (push) Has been cancelled
web-platform-ci / Standalone decision service (no cloud credentials) (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
lekiwi-compatibility / cpu-compatibility (pull_request) Has been cancelled
集成同源 BYOK 会话隔离、精简模型设置、官方订阅入口和 HTTPS 发布运维;保留本地训练/调参与控制能力。同步 npm 版本及 CHANGELOG,记录公网真实 API 验收仍待用户凭据。
351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
import type { SimulationSession } from '../../simulation/SimulationSession';
|
|
import { ModelBindings } from '../../simulation/ModelBindings';
|
|
import { LeKiwiIK } from './LeKiwiIK';
|
|
import { PhysicalSampler, PickPlaceEvaluator, type PhysicalSample } from './PhysicalEvidence';
|
|
import { PICK_PLACE as P, PICK_PLACE_ROBOT as R, seedPositions } from './PickPlaceScene';
|
|
|
|
const phases = [
|
|
'open',
|
|
'pregrasp',
|
|
'descend',
|
|
'close',
|
|
'verify',
|
|
'carry',
|
|
'stop-base',
|
|
'place',
|
|
'release',
|
|
'retreat',
|
|
'settle',
|
|
] as const;
|
|
export type BaselinePhase = (typeof phases)[number];
|
|
const durations = [2, 6, 3, 3, 4, 22, 1, 4, 3, 4, 1];
|
|
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
|
|
|
/** No network, RL reward, object attachment or independent physics loop.
|
|
* The caller exclusively owns the existing simulation clock and validates writes.
|
|
* Ungated mode is the frozen Stage-5 reference. Gated mode waits at each verified
|
|
* skill boundary; only a current synchronous owner callback may authorize/recover. */
|
|
export class DeterministicBaseline {
|
|
readonly evaluator: PickPlaceEvaluator;
|
|
readonly trace: (PhysicalSample & {
|
|
phase: BaselinePhase;
|
|
secure: boolean;
|
|
transported: number;
|
|
})[] = [];
|
|
readonly ik: LeKiwiIK;
|
|
private readonly sampler: PhysicalSampler;
|
|
private readonly arm;
|
|
private readonly gripper;
|
|
private readonly wheels;
|
|
private readonly positions;
|
|
private graspSource: number[];
|
|
private readonly baseStart: number[];
|
|
private readonly servo: number[];
|
|
private readonly velocities = Array<number>(5).fill(0);
|
|
private readonly baseVelocity = [0, 0, 0];
|
|
private readonly translation: readonly number[];
|
|
private opening: number;
|
|
private phaseIndex = 0;
|
|
private phaseStart: number;
|
|
private readonly start: number;
|
|
private nextControl: number;
|
|
private lostGraspTime = 0;
|
|
private readonly armStallTime = Array<number>(5).fill(0);
|
|
private disposed = false;
|
|
private completedPhase = false;
|
|
waiting = false;
|
|
sample: PhysicalSample;
|
|
status: 'running' | 'succeeded' | 'failed' = 'running';
|
|
error = '';
|
|
maxJointSpeed = 0;
|
|
constructor(
|
|
private readonly session: SimulationSession,
|
|
seed: number,
|
|
private readonly writeServo: (actuator: number, value: number) => void,
|
|
private readonly gated = false,
|
|
goal?: readonly number[],
|
|
) {
|
|
const b = new ModelBindings(session.model);
|
|
this.positions = seedPositions(seed, goal);
|
|
this.translation = goal
|
|
? this.positions.goal.slice(0, 2).map((v, i) => v - this.positions.object[i])
|
|
: P.baseTranslation;
|
|
this.graspSource = [...this.positions.object];
|
|
this.waiting = gated;
|
|
this.arm = R.armJoints.map((spec, i) =>
|
|
b.scalarActuator(R.armActuators[i], spec.name, 'position'),
|
|
);
|
|
this.gripper = b.scalarActuator(R.gripperActuators[0].name, R.gripperJoint, 'position');
|
|
this.wheels = R.baseActuators.map((name, i) =>
|
|
b.scalarActuator(name, R.baseJoints[i], 'velocity'),
|
|
);
|
|
this.sampler = new PhysicalSampler(session);
|
|
let ik: LeKiwiIK | undefined;
|
|
try {
|
|
this.ik = ik = new LeKiwiIK(session);
|
|
this.sample = this.sampler.sample();
|
|
} catch (error) {
|
|
ik?.dispose();
|
|
this.sampler.dispose();
|
|
throw error;
|
|
}
|
|
this.baseStart = [...this.sample.base];
|
|
this.servo = [...this.sample.joints];
|
|
this.opening = this.sample.opening;
|
|
this.start = this.phaseStart = this.nextControl = this.sample.time;
|
|
this.evaluator = new PickPlaceEvaluator(this.positions.object, this.positions.goal);
|
|
}
|
|
get phase(): BaselinePhase {
|
|
return phases[this.phaseIndex];
|
|
}
|
|
get goal(): readonly number[] {
|
|
return this.positions.goal;
|
|
}
|
|
get candidate(): BaselinePhase {
|
|
return phases[this.phaseIndex + (this.completedPhase ? 1 : 0)] ?? 'settle';
|
|
}
|
|
/** Re-check facts even if both models recommend continuation. No model can grant this gate. */
|
|
authorize(skill: BaselinePhase): void {
|
|
if (!this.gated || !this.waiting || this.status !== 'running' || skill !== this.candidate)
|
|
throw new Error('非法技能切换');
|
|
const s = this.sample;
|
|
if (skill === 'pregrasp' && s.baseSpeed > 0.01) throw new Error('base_not_stopped');
|
|
if (skill === 'descend' && s.tcp[2] < s.object[2] + 0.04) throw new Error('tcp_not_above');
|
|
if (skill === 'close' && Math.hypot(...s.tcp.map((v, i) => v - s.object[i])) > 0.012)
|
|
throw new Error('alignment_timeout');
|
|
if (skill === 'verify' && s.fingerForces.some((v) => v < P.fingerForceMin))
|
|
throw new Error('empty_grasp');
|
|
if (['carry', 'stop-base'].includes(skill) && !this.evaluator.secure)
|
|
throw new Error('grasp_unverified');
|
|
if (skill === 'place' && (this.evaluator.transported < P.transportMin || s.baseSpeed > 0.01))
|
|
throw new Error('transport_unverified');
|
|
if (skill === 'release' && !s.onGoalSupport) throw new Error('placement_unsupported');
|
|
if (
|
|
skill === 'retreat' &&
|
|
(s.opening < 0.65 || s.fingerForces.some((v) => v >= P.fingerForceMin))
|
|
)
|
|
throw new Error('release_blocked');
|
|
if (this.completedPhase) this.phaseIndex++;
|
|
this.completedPhase = false;
|
|
this.waiting = false;
|
|
this.phaseStart = this.nextControl = s.time;
|
|
}
|
|
canRecover(): boolean {
|
|
const s = this.sample;
|
|
return (
|
|
this.gated &&
|
|
this.status === 'failed' &&
|
|
this.phaseIndex <= phases.indexOf('verify') &&
|
|
['alignment_timeout', 'empty_grasp', 'grasp_unverified'].includes(this.error) &&
|
|
s.supported &&
|
|
s.objectSpeed < P.placementSpeedMax &&
|
|
s.baseSpeed < 0.01 &&
|
|
this.evaluator.transported === 0 &&
|
|
Math.hypot(...s.object.map((v, i) => v - this.positions.object[i])) < 0.012
|
|
);
|
|
}
|
|
/** Only a supported, stationary object still at the source may be realigned/regrasped. */
|
|
recover(): void {
|
|
if (!this.canRecover()) throw new Error('unsafe_recovery');
|
|
this.hold();
|
|
// An empty/blocked attempt may not have reached its requested gripper position.
|
|
// Re-seed targets from measured pose so a released fault cannot cause a servo jump.
|
|
this.opening = this.sample.opening;
|
|
this.servo.splice(0, this.servo.length, ...this.sample.joints);
|
|
this.armStallTime.fill(0);
|
|
this.graspSource = [...this.sample.object];
|
|
this.phaseIndex = 0;
|
|
this.phaseStart = this.nextControl = this.sample.time;
|
|
this.completedPhase = false;
|
|
this.waiting = true;
|
|
this.status = 'running';
|
|
this.error = '';
|
|
// Do not reset the episode deadline or fabricate/reset the evaluator's transport history.
|
|
}
|
|
private target(): number[] | undefined {
|
|
const source = this.graspSource,
|
|
goal = this.positions.goal;
|
|
switch (this.phase) {
|
|
case 'pregrasp':
|
|
return [source[0], source[1], P.pregraspHeight];
|
|
case 'descend':
|
|
case 'close':
|
|
return [...source];
|
|
case 'verify':
|
|
return [source[0], source[1], P.carryHeight];
|
|
case 'place':
|
|
case 'release':
|
|
return [...goal];
|
|
case 'retreat':
|
|
case 'settle':
|
|
return [goal[0], goal[1], P.retreatHeight];
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
beforeStep(): void {
|
|
if (
|
|
this.disposed ||
|
|
this.waiting ||
|
|
this.status !== 'running' ||
|
|
this.sample.time + 1e-9 < this.nextControl
|
|
)
|
|
return;
|
|
this.nextControl = this.sample.time + P.controlDt;
|
|
try {
|
|
const target = this.target();
|
|
if (target) {
|
|
const solution = this.ik.solve(target);
|
|
if (solution.residual > 0.004) throw new Error('unreachable');
|
|
this.arm.forEach((joint, i) => {
|
|
const desired = clamp(5 * (solution.targets[i] - this.servo[i]), -P.armSpeed, P.armSpeed);
|
|
this.velocities[i] += clamp(
|
|
desired - this.velocities[i],
|
|
-P.armAcceleration * P.controlDt,
|
|
P.armAcceleration * P.controlDt,
|
|
);
|
|
const old = this.servo[i],
|
|
spec = R.armJoints[i];
|
|
const next = clamp(
|
|
clamp(
|
|
old + this.velocities[i] * P.controlDt,
|
|
this.sample.joints[i] - P.armTrackingError,
|
|
this.sample.joints[i] + P.armTrackingError,
|
|
),
|
|
old - P.armSpeed * P.controlDt,
|
|
old + P.armSpeed * P.controlDt,
|
|
);
|
|
this.servo[i] = clamp(next, spec.min, spec.max);
|
|
this.velocities[i] = (this.servo[i] - old) / P.controlDt;
|
|
});
|
|
}
|
|
this.arm.forEach((joint, i) => this.writeServo(joint.actuatorId, this.servo[i]));
|
|
const close = ['close', 'verify', 'carry', 'stop-base', 'place'].includes(this.phase);
|
|
this.opening += clamp(
|
|
(close ? 0 : 1) - this.opening,
|
|
-P.gripperOpeningRate * P.controlDt,
|
|
P.gripperOpeningRate * P.controlDt,
|
|
);
|
|
this.writeServo(
|
|
this.gripper.actuatorId,
|
|
R.gripperClosed + this.opening * (R.gripperOpen - R.gripperClosed),
|
|
);
|
|
const desired = [0, 0, 0];
|
|
if (this.phase === 'carry') {
|
|
const dx = this.baseStart[0] + this.translation[0] - this.sample.base[0];
|
|
const dy = this.baseStart[1] + this.translation[1] - this.sample.base[1];
|
|
const scale = Math.min(1, P.baseSpeed / Math.max(1e-12, Math.hypot(dx, dy)));
|
|
const c = Math.cos(this.sample.yaw),
|
|
s = Math.sin(this.sample.yaw);
|
|
desired[0] = scale * (c * dx + s * dy);
|
|
desired[1] = scale * (-s * dx + c * dy);
|
|
desired[2] = clamp(-1.5 * this.sample.yaw, -P.yawSpeed, P.yawSpeed);
|
|
}
|
|
for (let i = 0; i < 3; i++) {
|
|
const bound = (i === 2 ? P.yawAcceleration : P.baseAcceleration) * P.controlDt;
|
|
this.baseVelocity[i] += clamp(desired[i] - this.baseVelocity[i], -bound, bound);
|
|
}
|
|
const wheelSpeeds = R.baseMix.map((row) =>
|
|
row.reduce((sum, v, i) => sum + v * this.baseVelocity[i], 0),
|
|
);
|
|
const wheelScale = R.wheelLimit / Math.max(R.wheelLimit, ...wheelSpeeds.map(Math.abs));
|
|
this.wheels.forEach((wheel, i) =>
|
|
this.writeServo(wheel.actuatorId, wheelSpeeds[i] * wheelScale),
|
|
);
|
|
this.trace.push({
|
|
...this.sample,
|
|
phase: this.phase,
|
|
secure: this.evaluator.secure,
|
|
transported: this.evaluator.transported,
|
|
});
|
|
} catch (error) {
|
|
this.fail(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
afterStep(dt: number): void {
|
|
if (this.disposed || this.status !== 'running') return;
|
|
try {
|
|
const previous = this.sample;
|
|
this.sample = this.sampler.sample();
|
|
this.evaluator.update(this.sample, dt);
|
|
// Only arm joints: gripper contact intentionally blocks a closing servo.
|
|
// Frozen model waits do not call afterStep and cannot accumulate stall time.
|
|
for (let i = 0; i < this.arm.length; i++) {
|
|
const speed = Math.abs(this.sample.joints[i] - previous.joints[i]) / dt;
|
|
const blocked =
|
|
Math.abs(this.servo[i] - this.sample.joints[i]) >= P.armStallError &&
|
|
speed < P.armStallSpeed;
|
|
this.armStallTime[i] = blocked ? this.armStallTime[i] + dt : 0;
|
|
if (this.armStallTime[i] >= P.armStallDuration) throw new Error('joint_stall');
|
|
}
|
|
this.maxJointSpeed = Math.max(this.maxJointSpeed, this.sample.peakJointSpeed);
|
|
if (this.sample.peakJointSpeed > P.jointSpeedStop) throw new Error('joint_velocity');
|
|
if (this.sample.base[2] < 0.03 || this.sample.base[2] > 0.1 || this.sample.tilt > 0.35)
|
|
throw new Error('base_unstable');
|
|
if (this.sample.time - this.start > P.episodeTimeout) throw new Error('episode_timeout');
|
|
if (this.phase === 'carry' || this.phase === 'stop-base') {
|
|
this.lostGraspTime = this.evaluator.secure ? 0 : this.lostGraspTime + dt;
|
|
if (this.lostGraspTime > 0.2) throw new Error('lost_grasp');
|
|
}
|
|
let ready = this.sample.time - this.phaseStart >= durations[this.phaseIndex];
|
|
if (this.phase === 'carry') {
|
|
const distance = Math.hypot(
|
|
...this.sample.base
|
|
.slice(0, 2)
|
|
.map((v, i) => v - this.baseStart[i] - this.translation[i]),
|
|
);
|
|
if (distance < 0.007) ready = true;
|
|
else if (ready) throw new Error('navigation_timeout');
|
|
}
|
|
if (!ready) return;
|
|
const target = this.target();
|
|
if (target && Math.hypot(...target.map((v, i) => v - this.sample.tcp[i])) > 0.012)
|
|
throw new Error('alignment_timeout');
|
|
if (this.phase === 'close' && this.sample.fingerForces.some((f) => f < P.fingerForceMin))
|
|
throw new Error('empty_grasp');
|
|
if (this.phase === 'verify' && !this.evaluator.secure) throw new Error('grasp_unverified');
|
|
if (this.phase === 'carry' && this.evaluator.transported < P.transportMin)
|
|
throw new Error('transport_unverified');
|
|
if (this.phase === 'place' && !this.sample.onGoalSupport)
|
|
throw new Error('placement_unsupported');
|
|
if (
|
|
this.phase === 'release' &&
|
|
(this.sample.opening < 0.65 || this.sample.fingerForces.some((f) => f >= P.fingerForceMin))
|
|
)
|
|
throw new Error('release_blocked');
|
|
if (this.phase === 'settle') {
|
|
if (!this.evaluator.succeeded) throw new Error('placement_unverified');
|
|
this.status = 'succeeded';
|
|
this.hold();
|
|
} else if (this.gated) {
|
|
this.completedPhase = true;
|
|
this.waiting = true;
|
|
this.hold();
|
|
} else {
|
|
this.phaseIndex++;
|
|
this.phaseStart = this.sample.time;
|
|
}
|
|
} catch (error) {
|
|
this.fail(error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
/** Called only through the session's current owner write capability. */
|
|
hold(): void {
|
|
this.baseVelocity.fill(0);
|
|
this.velocities.fill(0);
|
|
this.wheels.forEach((wheel) => this.writeServo(wheel.actuatorId, 0));
|
|
// Keep arm/gripper's LAST safe servo targets; never zero loaded position servos.
|
|
}
|
|
private fail(reason: string): void {
|
|
this.status = 'failed';
|
|
this.error = reason;
|
|
this.hold();
|
|
}
|
|
dispose(): void {
|
|
if (this.disposed) return;
|
|
this.disposed = true;
|
|
this.sampler.dispose();
|
|
this.ik.dispose();
|
|
}
|
|
}
|