feat: add deterministic office robot jobs
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
import { Plan } from "../interiors/plan.ts";
|
||||
import {
|
||||
createRobotActivity,
|
||||
type RobotActivityController,
|
||||
type RobotActivitySnapshot,
|
||||
type RobotActivityState,
|
||||
} from "../interiors/robotActivity.ts";
|
||||
import {
|
||||
resolveRobotOperations,
|
||||
type ResolvedRobotOperations,
|
||||
type RobotJobKind,
|
||||
} from "../interiors/robotOperations.ts";
|
||||
import { LUMBRIDGE_HQ } from "../offices/lumbridge-hq.ts";
|
||||
import { MATEO_COURT } from "../offices/mateo-court.ts";
|
||||
import { LUMBRIDGE_HQ_ROBOT_OPERATIONS } from "../offices/operations/lumbridge-hq.ts";
|
||||
import { MATEO_COURT_ROBOT_OPERATIONS } from "../offices/operations/mateo-court.ts";
|
||||
import { BaseArenaEnvironment, type SimulationTransition } from "./base.ts";
|
||||
import { ARENA_SOURCE_HASHES } from "./sourceHashes.ts";
|
||||
import { ArenaScenarioRegistry } from "./scenarios.ts";
|
||||
import { ARENA_API_VERSION, type ArenaManifest, type ArenaScenario } from "./types.ts";
|
||||
|
||||
export interface OfficeJobsAction {
|
||||
x: number;
|
||||
z: number;
|
||||
interact: boolean;
|
||||
}
|
||||
|
||||
export interface OfficeJobsObservation {
|
||||
officeId: string;
|
||||
robotId: string;
|
||||
levelId: string;
|
||||
x: number;
|
||||
z: number;
|
||||
mode: string;
|
||||
phase: string;
|
||||
jobKind: RobotJobKind | "none";
|
||||
payload: "parcel" | null;
|
||||
battery: number;
|
||||
jobProgress: number;
|
||||
nextStationId: string;
|
||||
nextX: number;
|
||||
nextZ: number;
|
||||
deltaX: number;
|
||||
deltaZ: number;
|
||||
distanceToNextM: number;
|
||||
canInteract: boolean;
|
||||
blockedStreak: number;
|
||||
recoveryCount: number;
|
||||
completedJobs: number;
|
||||
}
|
||||
|
||||
export type OfficeJobsReward = Record<
|
||||
"navigation" | "job" | "success" | "time" | "control" | "collision" | "interaction" | "safety",
|
||||
number
|
||||
>;
|
||||
|
||||
interface OfficeJobsScenarioParameters {
|
||||
officeId: "lumbridge-hq" | "mateo-court";
|
||||
robotId: string;
|
||||
jobId: string;
|
||||
}
|
||||
|
||||
interface OfficeJobsSimulationSnapshot {
|
||||
activity: RobotActivitySnapshot;
|
||||
previousDistanceM: number;
|
||||
previousProgress: number;
|
||||
blockedStreak: number;
|
||||
wrongInteractions: number;
|
||||
initialCompletedJobs: number;
|
||||
}
|
||||
|
||||
const MAX_STEPS = 720;
|
||||
// Slightly inside robotActivity's 0.30 m arrival radius so an agent never sees
|
||||
// an interaction affordance the authoritative controller would still reject.
|
||||
const INTERACTION_RADIUS_M = 0.295;
|
||||
const WRONG_INTERACTION_LIMIT = 8;
|
||||
|
||||
const SF_PLAN = new Plan(LUMBRIDGE_HQ, { depth: "public", warn: false });
|
||||
const LA_PLAN = new Plan(MATEO_COURT, { depth: "public", warn: false });
|
||||
const SF_OPERATIONS = resolveRobotOperations(SF_PLAN, LUMBRIDGE_HQ_ROBOT_OPERATIONS);
|
||||
const LA_OPERATIONS = resolveRobotOperations(LA_PLAN, MATEO_COURT_ROBOT_OPERATIONS);
|
||||
|
||||
const DEFINITIONS = [
|
||||
{
|
||||
id: "train-sf-display-inspection",
|
||||
split: "train" as const,
|
||||
parameters: { officeId: "lumbridge-hq" as const, robotId: "sf-l1-inspector", jobId: "sf-l1-inspect-display" },
|
||||
},
|
||||
{
|
||||
id: "train-la-directory-inspection",
|
||||
split: "train" as const,
|
||||
parameters: { officeId: "mateo-court" as const, robotId: "la-l1-inspector", jobId: "la-l1-inspect-directory" },
|
||||
},
|
||||
{
|
||||
id: "dev-sf-parcel-delivery",
|
||||
split: "dev" as const,
|
||||
parameters: { officeId: "lumbridge-hq" as const, robotId: "sf-l1-courier", jobId: "sf-l1-deliver" },
|
||||
},
|
||||
{
|
||||
id: "dev-la-loft-delivery",
|
||||
split: "dev" as const,
|
||||
parameters: { officeId: "mateo-court" as const, robotId: "la-l2-courier", jobId: "la-l2-deliver" },
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const OFFICE_JOBS_SCENARIOS = new ArenaScenarioRegistry<OfficeJobsScenarioParameters>(
|
||||
"office-jobs-v1",
|
||||
DEFINITIONS,
|
||||
(parameters) => ({ ...parameters }),
|
||||
);
|
||||
|
||||
export const OFFICE_JOBS_MANIFEST: ArenaManifest = Object.freeze({
|
||||
apiVersion: ARENA_API_VERSION,
|
||||
id: "office-jobs-v1",
|
||||
version: 1,
|
||||
title: "Seeded office robot jobs",
|
||||
description: "Headless job execution over explicit simulated SF/LA operations and resolved office collision.",
|
||||
simulator: "Plan + robotRoutes + fixed-step robotActivity",
|
||||
fixedStepSeconds: 0.1,
|
||||
maxSteps: MAX_STEPS,
|
||||
actionFields: ["x", "z", "interact"],
|
||||
observationFields: [
|
||||
"officeId", "robotId", "levelId", "x", "z", "mode", "phase", "jobKind", "payload",
|
||||
"battery", "jobProgress", "nextStationId", "nextX", "nextZ", "deltaX", "deltaZ",
|
||||
"distanceToNextM", "canInteract", "blockedStreak", "recoveryCount", "completedJobs",
|
||||
],
|
||||
rewardComponents: {
|
||||
navigation: "Bounded reduction in distance to the current authored job station.",
|
||||
job: "Dense progress through pickup, delivery, inspection, or charge phases.",
|
||||
success: "Sparse bonus for completing the assigned job.",
|
||||
time: "Per-step pressure that keeps inaction below zero.",
|
||||
control: "Small cost on planar action magnitude.",
|
||||
collision: "Cost for commanded movement that fails against resolved collision.",
|
||||
interaction: "Penalty for interaction away from the current work station.",
|
||||
safety: "Terminal collision-stall or repeated wrong-interaction penalty.",
|
||||
},
|
||||
safetyTerminals: ["collision-stall", "wrong-interaction-limit"],
|
||||
scenarioIds: {
|
||||
train: OFFICE_JOBS_SCENARIOS.ids("train"),
|
||||
dev: OFFICE_JOBS_SCENARIOS.ids("dev"),
|
||||
},
|
||||
baselines: {
|
||||
inaction: "x=0,z=0,interact=false never completes a job and accumulates the time floor.",
|
||||
scripted: "Follow the exposed current route waypoint and interact only inside the station radius.",
|
||||
},
|
||||
});
|
||||
|
||||
export const OFFICE_JOBS_INACTION: Readonly<OfficeJobsAction> = Object.freeze({
|
||||
x: 0,
|
||||
z: 0,
|
||||
interact: false,
|
||||
});
|
||||
|
||||
export function officeJobsScriptedBaseline(observation: OfficeJobsObservation): OfficeJobsAction {
|
||||
if (observation.canInteract) return { x: 0, z: 0, interact: true };
|
||||
const length = Math.hypot(observation.deltaX, observation.deltaZ);
|
||||
if (length <= 1e-9) return { x: 0, z: 0, interact: false };
|
||||
const magnitude = Math.min(1, length / (1.05 * OFFICE_JOBS_MANIFEST.fixedStepSeconds));
|
||||
return {
|
||||
x: observation.deltaX / length * magnitude,
|
||||
z: observation.deltaZ / length * magnitude,
|
||||
interact: false,
|
||||
};
|
||||
}
|
||||
|
||||
export class OfficeJobsEnvironment extends BaseArenaEnvironment<
|
||||
OfficeJobsAction,
|
||||
OfficeJobsObservation,
|
||||
OfficeJobsReward,
|
||||
OfficeJobsSimulationSnapshot,
|
||||
OfficeJobsScenarioParameters
|
||||
> {
|
||||
readonly manifest = OFFICE_JOBS_MANIFEST;
|
||||
protected readonly registry = OFFICE_JOBS_SCENARIOS;
|
||||
protected readonly sourceHashes = ARENA_SOURCE_HASHES["office-jobs-v1"]!;
|
||||
private activity: RobotActivityController | null = null;
|
||||
private operations: ResolvedRobotOperations | null = null;
|
||||
private robotId = "";
|
||||
private previousDistanceM = 0;
|
||||
private previousProgress = 0;
|
||||
private blockedStreak = 0;
|
||||
private wrongInteractions = 0;
|
||||
private initialCompletedJobs = 0;
|
||||
|
||||
protected resetSimulation(
|
||||
scenario: ArenaScenario<OfficeJobsScenarioParameters>,
|
||||
): OfficeJobsObservation {
|
||||
const selected = setupFor(scenario.parameters.officeId);
|
||||
this.operations = selected.operations;
|
||||
this.robotId = scenario.parameters.robotId;
|
||||
this.activity = createRobotActivity(selected.plan, selected.operations, {
|
||||
seed: scenario.seed,
|
||||
robotIds: [this.robotId],
|
||||
controlledRobotIds: [this.robotId],
|
||||
initialJobIds: { [this.robotId]: scenario.parameters.jobId },
|
||||
});
|
||||
const robot = this.robot();
|
||||
this.previousDistanceM = this.distanceToNext(robot);
|
||||
this.previousProgress = robot.progress;
|
||||
this.blockedStreak = 0;
|
||||
this.wrongInteractions = 0;
|
||||
this.initialCompletedJobs = robot.completedJobs;
|
||||
return this.observation();
|
||||
}
|
||||
|
||||
protected normalizeAction(action: OfficeJobsAction): OfficeJobsAction {
|
||||
const x = Number.isFinite(action?.x) ? action.x : 0;
|
||||
const z = Number.isFinite(action?.z) ? action.z : 0;
|
||||
const length = Math.hypot(x, z);
|
||||
return {
|
||||
x: length > 1 ? x / length : x,
|
||||
z: length > 1 ? z / length : z,
|
||||
interact: action?.interact === true,
|
||||
};
|
||||
}
|
||||
|
||||
protected advanceSimulation(
|
||||
action: OfficeJobsAction,
|
||||
): SimulationTransition<OfficeJobsObservation, OfficeJobsReward> {
|
||||
const before = this.robot();
|
||||
const beforePosition = { ...before.position };
|
||||
const canInteract = this.canInteract(before);
|
||||
if (action.interact && !canInteract) this.wrongInteractions += 1;
|
||||
const after = this.requireActivity().step({ [this.robotId]: action }).robots[0]!;
|
||||
const moved = Math.hypot(after.position.x - beforePosition.x, after.position.z - beforePosition.z);
|
||||
const demand = Math.hypot(action.x, action.z);
|
||||
const blocked = demand > 0.2 && moved < 0.012;
|
||||
this.blockedStreak = blocked ? this.blockedStreak + 1 : 0;
|
||||
const distance = this.distanceToNext(after);
|
||||
const navigationProgress = Math.max(-0.3, Math.min(0.3, this.previousDistanceM - distance));
|
||||
const jobProgress = Math.max(-0.5, Math.min(0.5, after.progress - this.previousProgress));
|
||||
this.previousDistanceM = distance;
|
||||
this.previousProgress = after.progress;
|
||||
const success = after.completedJobs > this.initialCompletedJobs;
|
||||
const collisionStall = after.mode === "blocked-recovery";
|
||||
const wrongInteractionFailure = this.wrongInteractions >= WRONG_INTERACTION_LIMIT;
|
||||
const controllerFailure = after.terminalReason;
|
||||
const terminated = success || collisionStall || wrongInteractionFailure || controllerFailure !== null;
|
||||
const terminalReason = success
|
||||
? "job-complete"
|
||||
: collisionStall
|
||||
? "collision-stall"
|
||||
: wrongInteractionFailure
|
||||
? "wrong-interaction-limit"
|
||||
: controllerFailure ?? undefined;
|
||||
return {
|
||||
observation: this.observation(),
|
||||
rewardComponents: {
|
||||
navigation: navigationProgress * 0.18,
|
||||
job: jobProgress * 1.2,
|
||||
success: success ? 8 : 0,
|
||||
time: -0.006,
|
||||
control: -0.001 * demand ** 2,
|
||||
collision: blocked ? -0.08 : 0,
|
||||
interaction: action.interact && !canInteract ? -0.12 : 0,
|
||||
safety: collisionStall || wrongInteractionFailure || controllerFailure ? -2 : 0,
|
||||
},
|
||||
terminated,
|
||||
terminalReason,
|
||||
};
|
||||
}
|
||||
|
||||
protected simulationSnapshot(): OfficeJobsSimulationSnapshot {
|
||||
return {
|
||||
activity: this.requireActivity().snapshot(),
|
||||
previousDistanceM: this.previousDistanceM,
|
||||
previousProgress: this.previousProgress,
|
||||
blockedStreak: this.blockedStreak,
|
||||
wrongInteractions: this.wrongInteractions,
|
||||
initialCompletedJobs: this.initialCompletedJobs,
|
||||
};
|
||||
}
|
||||
|
||||
protected restoreSimulation(snapshot: OfficeJobsSimulationSnapshot): OfficeJobsObservation {
|
||||
this.resetSimulation(this.currentScenario());
|
||||
if (
|
||||
!Number.isFinite(snapshot.previousDistanceM) || !Number.isFinite(snapshot.previousProgress) ||
|
||||
!Number.isSafeInteger(snapshot.blockedStreak) || snapshot.blockedStreak < 0 ||
|
||||
!Number.isSafeInteger(snapshot.wrongInteractions) || snapshot.wrongInteractions < 0 ||
|
||||
!Number.isSafeInteger(snapshot.initialCompletedJobs) || snapshot.initialCompletedJobs < 0
|
||||
) throw new Error("office jobs simulation snapshot is invalid");
|
||||
this.requireActivity().restore(snapshot.activity);
|
||||
this.previousDistanceM = snapshot.previousDistanceM;
|
||||
this.previousProgress = snapshot.previousProgress;
|
||||
this.blockedStreak = snapshot.blockedStreak;
|
||||
this.wrongInteractions = snapshot.wrongInteractions;
|
||||
this.initialCompletedJobs = snapshot.initialCompletedJobs;
|
||||
return this.observation();
|
||||
}
|
||||
|
||||
private observation(): OfficeJobsObservation {
|
||||
const robot = this.robot();
|
||||
const station = this.nextStation(robot);
|
||||
const waypoint = robot.route[robot.routeIndex] ?? station?.position ?? robot.position;
|
||||
const distance = station ? this.distanceToNext(robot) : 0;
|
||||
return {
|
||||
officeId: this.requireOperations().definition.officeId,
|
||||
robotId: robot.id,
|
||||
levelId: robot.levelId,
|
||||
x: robot.position.x,
|
||||
z: robot.position.z,
|
||||
mode: robot.mode,
|
||||
phase: robot.phase,
|
||||
jobKind: robot.activeJobKind ?? "none",
|
||||
payload: robot.payload,
|
||||
battery: robot.battery,
|
||||
jobProgress: robot.progress,
|
||||
nextStationId: station?.id ?? "",
|
||||
nextX: waypoint.x,
|
||||
nextZ: waypoint.z,
|
||||
deltaX: waypoint.x - robot.position.x,
|
||||
deltaZ: waypoint.z - robot.position.z,
|
||||
distanceToNextM: distance,
|
||||
canInteract: this.canInteract(robot),
|
||||
blockedStreak: this.blockedStreak,
|
||||
recoveryCount: robot.recoveryCount,
|
||||
completedJobs: robot.completedJobs,
|
||||
};
|
||||
}
|
||||
|
||||
private nextStation(robot: RobotActivityState) {
|
||||
const id = robot.activeStationIds[robot.stationIndex];
|
||||
return id ? this.requireOperations().stations.get(id) : undefined;
|
||||
}
|
||||
|
||||
private distanceToNext(robot: RobotActivityState): number {
|
||||
const station = this.nextStation(robot);
|
||||
return station ? Math.hypot(station.position.x - robot.position.x, station.position.z - robot.position.z) : 0;
|
||||
}
|
||||
|
||||
private canInteract(robot: RobotActivityState): boolean {
|
||||
return this.distanceToNext(robot) <= INTERACTION_RADIUS_M;
|
||||
}
|
||||
|
||||
private robot(): RobotActivityState {
|
||||
const robot = this.requireActivity().states()[0];
|
||||
if (!robot) throw new Error("office jobs environment has no controlled robot");
|
||||
return robot;
|
||||
}
|
||||
|
||||
private requireActivity(): RobotActivityController {
|
||||
if (!this.activity) throw new Error("office jobs environment has not been reset");
|
||||
return this.activity;
|
||||
}
|
||||
|
||||
private requireOperations(): ResolvedRobotOperations {
|
||||
if (!this.operations) throw new Error("office jobs environment has not been reset");
|
||||
return this.operations;
|
||||
}
|
||||
}
|
||||
|
||||
function setupFor(officeId: OfficeJobsScenarioParameters["officeId"]) {
|
||||
return officeId === "lumbridge-hq"
|
||||
? { plan: SF_PLAN, operations: SF_OPERATIONS }
|
||||
: { plan: LA_PLAN, operations: LA_OPERATIONS };
|
||||
}
|
||||
Reference in New Issue
Block a user